diff --git a/docs/llm-wiki/08-auth-and-terms-flow.md b/docs/llm-wiki/08-auth-and-terms-flow.md
index b7b4c3c9..c9b383ba 100644
--- a/docs/llm-wiki/08-auth-and-terms-flow.md
+++ b/docs/llm-wiki/08-auth-and-terms-flow.md
@@ -40,7 +40,7 @@ Platform UI auth and JupyterHub auth are related but separate:
8. Hub sets its own session cookie (SameSite=Lax, Secure by default). Any valid Keycloak user for the Hub client is currently allowed (`allow_all = True`).
9. Before spawn, Hub injects the Keycloak access token into the notebook pod as `MIP_TOKEN`, refreshing via stored refresh tokens when needed.
10. Running notebooks can refresh `MIP_TOKEN` through Hub `/notebook/hub/api/platform-token` via the mip client transport.
-11. Remote use (account page, notebook flag only): the user creates a personal Hub API token at `/notebook/hub/token` (a top-level navigation that nginx proxies to Hub, not back into the shell) sets `MIP_URL` and runs `mip login` (which saves the token to a file) for the mip client on their own computer. The UI only links to Hub's page; it never requests, stores or shows the token.
+11. Remote use (account page): shown when `GET /services/auth-config` answers (the backend has a public Keycloak client, `KEYCLOAK_CLI_CLIENT_ID`). "Create access token" runs an authorization-code sign-in with PKCE as that client (`scope=openid offline_access`, redirect back to `/account`, code exchanged by the browser, `cli-token.ts`) and shows the offline refresh token once; nothing stores it. Users paste it into `mip login --token` or `MIP_API_TOKEN`; the mip client renews access with Keycloak itself. The page also explains `mip login` (device flow). JupyterHub is not involved.
Key files: `src/app/pages/notebook/hub-session.ts`, `hub-http.ts`, `jupyterhub-api.ts`, `nginx.conf.template`.
diff --git a/src/app/pages/account-page/account-page.component.css b/src/app/pages/account-page/account-page.component.css
index 1dbdba37..8ae22fcc 100644
--- a/src/app/pages/account-page/account-page.component.css
+++ b/src/app/pages/account-page/account-page.component.css
@@ -489,25 +489,14 @@
}
/* ===== Use MIP from your own computer ===== */
-.remote-steps {
- margin: 0;
- padding-left: 20px;
+.remote-access {
font-size: 14px;
- color: #111827;
line-height: 1.5;
+ color: #111827;
}
-.remote-steps li + li {
- margin-top: 14px;
-}
-
-.remote-steps p {
- margin: 8px 0 0;
- color: #6b7280;
-}
-
-.remote-steps pre {
- margin: 8px 0 0;
+.remote-access pre {
+ margin: 0;
padding: 10px 12px;
border-radius: 10px;
background: #f9fafb;
@@ -517,25 +506,64 @@
overflow-x: auto;
}
-.remote-steps code {
+.remote-access code {
font-family: var(--font-mono);
}
-.token-btn {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- background-color: var(--primary-color);
- color: #ffffff;
- padding: 8px 14px;
- border-radius: var(--radius-md);
- font-size: 14px;
- font-weight: 500;
- text-decoration: none;
+/* Inline code only; blocks are styled by pre above. */
+.remote-access :not(pre) > code {
+ padding: 1px 5px;
+ border-radius: 4px;
+ background: #f3f4f6;
+ font-size: 0.92em;
}
-.token-btn:hover {
- background-color: var(--primary-dark);
+.remote-text {
+ margin: 14px 0 8px;
+}
+
+.remote-note {
+ margin: 6px 0 0;
+ font-size: 13px;
+ color: #6b7280;
+}
+
+.account-link {
+ color: var(--primary-color);
+}
+
+.remote-options {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
+ gap: 12px 16px;
+}
+
+.remote-option {
+ padding: 14px 16px;
+ border-radius: 10px;
+ background: #f9fafb;
+ border: 1px solid #e5e7eb;
+ min-width: 0;
+}
+
+.remote-option pre {
+ margin-top: 10px;
+ background: #ffffff;
+}
+
+.remote-option :not(pre) > code {
+ background: #e5e7eb;
+}
+
+.remote-option .remote-text {
+ margin: 10px 0 0;
+}
+
+.remote-access h4 {
+ margin: 0 0 10px;
+ font-size: 14px;
+ font-weight: 600;
+ color: #111827;
}
.snippet {
@@ -573,3 +601,36 @@
font-size: 12px;
color: #6b7280;
}
+
+.create-token-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ background-color: var(--primary-color);
+ color: #ffffff;
+ border: none;
+ padding: 8px 14px;
+ border-radius: var(--radius-md);
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+}
+
+.create-token-btn:hover:not(:disabled) {
+ background-color: var(--primary-dark);
+}
+
+.create-token-btn:disabled {
+ opacity: 0.6;
+ cursor: default;
+}
+
+.token-value code {
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+
+.token-error {
+ margin: 8px 0 0;
+ color: #991b1b;
+}
diff --git a/src/app/pages/account-page/account-page.component.html b/src/app/pages/account-page/account-page.component.html
index 849aa971..b20a6a36 100644
--- a/src/app/pages/account-page/account-page.component.html
+++ b/src/app/pages/account-page/account-page.component.html
@@ -44,41 +44,76 @@
Profile Information
- @if (notebookEnabled) {
+ @if (authConfig()) {
Use MIP from your own computer
-
- -
-
+
+
Install the client and point it at this portal:
+
+
{{ setupSnippet }}
+
+
+ {{ copyStatus() }}
+ In Windows PowerShell, write $env:MIP_URL = "…" instead of export.
+
+ Then sign in, one of two ways:
+
+
+
No browser on that machine
+
+
You confirm with your MIP sign-in. The token is shown only once.
+ @if (tokenError()) {
+
{{ tokenError() }}
+ }
+ @if (cliToken()) {
+
+
{{ cliToken() }}
+
+
+
{{ tokenCopyStatus() }}
+
+ Treat it like a password: it gives your full MIP data access for up to 6 months, until
+ it goes unused for 30 days, or until you run mip logout.
+
+ }
+
+ On that machine, run mip login --token and paste it. Scripts can set
+ MIP_API_TOKEN instead, but every program started from there, AI assistants
+ included, can read it.
-
-
-
- In a terminal on your computer, run:
-
-
{{ setupSnippet }}
-
-
- {{ copyStatus() }}
-
- mip login asks for your token without showing it.
- In Windows PowerShell, write $env:MIP_URL = "…" instead of
- export MIP_URL=….
+
+
+
+
A browser on any device
+
+ Run mip login. It shows a link and a code: open the link, sign in, check
+ that the page shows the same code, and approve.
-
Then start Python in the same terminal and run:
-
import mip; client = mip.Client.from_env()
-
-
+
Never approve a code that someone else sent you.
+
+
+
+ Then, in Python:
+ import mip
+client = mip.Client.from_env()
+
+ Each sign-in lasts at most 6 months, and ends after 30 days without use. To end it sooner,
+ run mip logout on that computer. Without that computer, remove
+ "MIP command line" under Applications in your
+ sign-in account: that ends every computer's sign-in.
+
}
diff --git a/src/app/pages/account-page/account-page.component.spec.ts b/src/app/pages/account-page/account-page.component.spec.ts
index cb023816..7e82d8cf 100644
--- a/src/app/pages/account-page/account-page.component.spec.ts
+++ b/src/app/pages/account-page/account-page.component.spec.ts
@@ -1,15 +1,25 @@
+import { provideHttpClient } from '@angular/common/http';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
+import { Location } from '@angular/common';
import { provideZonelessChangeDetection, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { AccountPageComponent } from './account-page.component';
import { AuthService } from '../../services/auth.service';
-import { RuntimeEnvService } from '../../services/runtime-env.service';
import { createSpyObj } from '../../testing/spy-obj.testing';
+import { cliTokenSignInUrl } from './cli-token';
describe('AccountPageComponent', () => {
- afterEach(() => vi.useRealTimers());
+ const startUrl = window.location.pathname + window.location.search;
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ sessionStorage.clear();
+ history.replaceState(null, '', startUrl);
+ });
- function render(runtimeEnv: Partial) {
+ // offered: whether the backend has a Keycloak client for `mip login` (GET /services/auth-config).
+ function render(offered = true) {
const authService = createSpyObj('AuthService', ['logout'], {
authState: signal({ status: 'authenticated', user: { fullname: 'Marie Curie', email: 'm@c.eu' } }),
});
@@ -18,38 +28,132 @@ describe('AccountPageComponent', () => {
providers: [
provideZonelessChangeDetection(),
provideRouter([]),
+ provideHttpClient(),
+ provideHttpClientTesting(),
{ provide: AuthService, useValue: authService },
- { provide: RuntimeEnvService, useValue: runtimeEnv },
],
});
const fixture = TestBed.createComponent(AccountPageComponent);
+ const authConfig = TestBed.inject(HttpTestingController).expectOne('/services/auth-config');
+ if (offered) {
+ authConfig.flush({ issuer: 'https://idp.example/realms/MIP', clientId: 'mip-cli' });
+ } else {
+ authConfig.flush(null, { status: 404, statusText: 'Not Found' });
+ }
fixture.detectChanges();
return fixture;
}
- it('hides the remote access section when the notebook is disabled', () => {
- const el: HTMLElement = render({ notebookEnabled: false, jupyterContextPath: '/notebook' }).nativeElement;
+ it('hides the remote access section when the platform does not offer it', () => {
+ const el: HTMLElement = render(false).nativeElement;
expect(el.querySelector('.remote-access')).toBeNull();
});
- it('links to the Hub token page under the configured context path', () => {
- const el: HTMLElement = render({ notebookEnabled: true, jupyterContextPath: '/jupyter' }).nativeElement;
+ it('explains both ways to sign in and never sends users to the JupyterHub token page', () => {
+ const el: HTMLElement = render().nativeElement;
+
+ const text = el.querySelector('.remote-access')!.textContent!;
+ expect(text).toContain('mip login --token');
+ expect(text).toContain('MIP_API_TOKEN');
+ expect(text).toContain('mip logout');
+ expect(text).not.toContain('custom:mip-platform-token');
+ expect(el.querySelector('a[href*="/hub/token"]')).toBeNull();
+ expect(el.querySelector('.token-value')).toBeNull();
+ });
+
+ it('links to the Keycloak account page to end every computer\'s sign-in', () => {
+ const link = (render().nativeElement as HTMLElement).querySelector('.account-link')!;
+
+ expect(link.getAttribute('href')).toBe('https://idp.example/realms/MIP/account/applications');
+ expect(link.target).toBe('_blank');
+ expect(link.rel).toBe('noopener');
+ });
+
+ it('starts the token sign-in at Keycloak as the public client', async () => {
+ const fixture = render();
+ const redirect = vi.spyOn(fixture.componentInstance as unknown as { redirect: (url: string) => void }, 'redirect')
+ .mockImplementation(() => {});
+
+ (fixture.nativeElement.querySelector('.create-token-btn') as HTMLButtonElement).click();
+ await fixture.whenStable();
+
+ const url = new URL(redirect.mock.calls[0][0]);
+ expect(url.origin + url.pathname).toBe('https://idp.example/realms/MIP/protocol/openid-connect/auth');
+ expect(url.searchParams.get('client_id')).toBe('mip-cli');
+ expect(url.searchParams.get('redirect_uri')).toBe(`${window.location.origin}/account`);
+ });
+
+ it('shows the token once when Keycloak sends the browser back, and drops the code from the address', async () => {
+ const config = { issuer: 'https://idp.example/realms/MIP', clientId: 'mip-cli' };
+ const state = new URL(await cliTokenSignInUrl(config, `${window.location.origin}/account`)).searchParams.get('state')!;
+ history.replaceState(null, '', `/account?code=one-time-code&state=${state}`);
+ vi.spyOn(window, 'fetch').mockResolvedValue(
+ new Response(JSON.stringify({ access_token: 'a', refresh_token: 'offline-token' }), { status: 200 }),
+ );
+
+ const fixture = render();
+ await vi.waitFor(() => {
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('.token-value').textContent).toContain('offline-token');
+ });
+
+ expect(TestBed.inject(Location).path()).toBe('/account');
+ expect(sessionStorage.length).toBe(0);
+ });
+
+ it('drops a code it did not ask for from the address and says so, without exchanging it', async () => {
+ history.replaceState(null, '', '/account?code=one-time-code&state=elsewhere');
+ const fetchSpy = vi.spyOn(window, 'fetch');
+
+ const fixture = render();
+ await vi.waitFor(() => {
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('.token-error').textContent).toContain('not started on this page');
+ });
+
+ expect(TestBed.inject(Location).path()).toBe('/account');
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it('shows why the sign-in could not start and lets the user try again', async () => {
+ vi.spyOn(crypto.subtle, 'digest').mockRejectedValue(new Error('no subtle crypto'));
+ const fixture = render();
+ const button = fixture.nativeElement.querySelector('.create-token-btn') as HTMLButtonElement;
- const tokenLink = el.querySelector('.token-btn')!;
- expect(tokenLink.getAttribute('href')).toBe('/jupyter/hub/token');
- expect(tokenLink.target).toBe('_blank');
+ button.click();
+ await vi.waitFor(() => {
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('.token-error').textContent).toContain('no subtle crypto');
+ });
+ expect(button.disabled).toBe(false);
+ });
+
+ it('enables the button again when the browser comes back from Keycloak through its back/forward cache', async () => {
+ const fixture = render();
+ const redirect = vi.spyOn(fixture.componentInstance as unknown as { redirect: (url: string) => void }, 'redirect')
+ .mockImplementation(() => {});
+ const button = fixture.nativeElement.querySelector('.create-token-btn') as HTMLButtonElement;
+
+ button.click();
+ await vi.waitFor(() => expect(redirect).toHaveBeenCalled());
+ fixture.detectChanges();
+ expect(button.disabled).toBe(true);
+
+ window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }));
+ fixture.detectChanges();
+ expect(button.disabled).toBe(false);
});
it('copies a setup snippet built from the current origin, with no real token', async () => {
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue();
- const fixture = render({ notebookEnabled: true, jupyterContextPath: '/notebook' });
+ const fixture = render();
- (fixture.nativeElement.querySelector('.copy-btn') as HTMLButtonElement).click();
+ (fixture.nativeElement.querySelector('.snippet-copy-btn') as HTMLButtonElement).click();
await fixture.whenStable();
expect(writeText).toHaveBeenCalledWith(
- `pip install mip-client\nexport MIP_URL=${window.location.origin}\nmip login`,
+ `pip install mip-client\nexport MIP_URL=${window.location.origin}`,
);
expect(fixture.nativeElement.querySelector('.copy-status').textContent).toContain('Copied');
});
@@ -57,20 +161,20 @@ describe('AccountPageComponent', () => {
it('clears the copy status so the next copy is announced again', async () => {
vi.useFakeTimers();
vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue();
- const fixture = render({ notebookEnabled: true, jupyterContextPath: '/notebook' });
+ const fixture = render();
- await fixture.componentInstance.copySetupSnippet();
- expect(fixture.componentInstance.copyStatus()).toBe('Copied');
+ const page = fixture.componentInstance;
+ await page.copy(page.setupSnippet, page.copyStatus);
+ expect(page.copyStatus()).toBe('Copied');
vi.advanceTimersByTime(2400);
- expect(fixture.componentInstance.copyStatus()).toBe('');
+ expect(page.copyStatus()).toBe('');
});
it('tells the user to copy by hand when the clipboard refuses', async () => {
vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue(new Error('denied'));
- vi.spyOn(console, 'warn').mockImplementation(() => {});
- const fixture = render({ notebookEnabled: true, jupyterContextPath: '/notebook' });
+ const fixture = render();
- (fixture.nativeElement.querySelector('.copy-btn') as HTMLButtonElement).click();
+ (fixture.nativeElement.querySelector('.snippet-copy-btn') as HTMLButtonElement).click();
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('.copy-status').textContent).toContain('Could not copy');
diff --git a/src/app/pages/account-page/account-page.component.ts b/src/app/pages/account-page/account-page.component.ts
index 1aa3a281..c4507d12 100644
--- a/src/app/pages/account-page/account-page.component.ts
+++ b/src/app/pages/account-page/account-page.component.ts
@@ -1,8 +1,11 @@
-import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, inject, signal, WritableSignal } from '@angular/core';
+import { toSignal } from '@angular/core/rxjs-interop';
import { CommonModule, Location } from '@angular/common';
+import { HttpClient } from '@angular/common/http';
import { RouterModule } from '@angular/router';
+import { catchError, of } from 'rxjs';
import { AuthService } from "../../services/auth.service";
-import { RuntimeEnvService } from '../../services/runtime-env.service';
+import { AuthConfig, cliTokenSignInUrl, finishCliTokenSignIn, isSignInReturn } from './cli-token';
@Component({
selector: 'app-account-page',
@@ -14,26 +17,59 @@ import { RuntimeEnvService } from '../../services/runtime-env.service';
export class AccountPageComponent {
public authService = inject(AuthService);
private location = inject(Location);
- private runtimeEnv = inject(RuntimeEnvService);
+ private http = inject(HttpClient);
userName = computed(() => this.authService.authState().user?.fullname ?? '');
userEmail = computed(() => this.authService.authState().user?.email ?? '');
- // Remote access goes through JupyterHub, which holds the user's Keycloak login;
- // the token is created on Hub's own page, so this UI never sees it.
- readonly notebookEnabled = this.runtimeEnv.notebookEnabled;
- readonly hubTokenUrl = `${this.runtimeEnv.jupyterContextPath}/hub/token`;
- readonly setupSnippet = [
- 'pip install mip-client',
- `export MIP_URL=${window.location.origin}`,
- // Asks for the token without echoing it and saves it to a file, so it stays out of
- // the shell history and of the environment every program (AI assistants too) inherits.
- 'mip login',
- ].join('\n');
+ // The platform's public Keycloak client for the mip client; the backend answers 404
+ // when there is none, and the remote access guide stays hidden.
+ readonly authConfig = toSignal(
+ this.http.get('/services/auth-config').pipe(catchError(() => of(null))),
+ { initialValue: null },
+ );
+ // Keycloak sends the browser back here after the sign-in for a new token.
+ private readonly tokenRedirectUri = `${window.location.origin}/account`;
+ // Shown once; never stored, and gone when the page is left.
+ readonly cliToken = signal('');
+ readonly tokenError = signal('');
+ readonly creatingToken = signal(false);
+ readonly tokenCopyStatus = signal('');
+ readonly setupSnippet = ['pip install mip-client', `export MIP_URL=${window.location.origin}`].join('\n');
readonly copyStatus = signal('');
- private copyStatusTimer?: ReturnType;
- constructor() { }
+ constructor() {
+ const query = new URLSearchParams(window.location.search);
+ if (isSignInReturn(query)) {
+ // Take the one-time code out of the address bar and history right away, even when it is not ours.
+ this.location.replaceState('/account');
+ this.creatingToken.set(true);
+ finishCliTokenSignIn(query, this.tokenRedirectUri)
+ .then((token) => this.cliToken.set(token))
+ .catch((err: Error) => this.tokenError.set(err.message))
+ .finally(() => this.creatingToken.set(false));
+ }
+ }
+
+ async createToken(): Promise {
+ const config = this.authConfig();
+ if (!config) return;
+ this.tokenError.set('');
+ this.creatingToken.set(true);
+ // Back from Keycloak can restore this page from the back/forward cache with the button still disabled.
+ window.addEventListener('pageshow', () => this.creatingToken.set(false), { once: true });
+ try {
+ this.redirect(await cliTokenSignInUrl(config, this.tokenRedirectUri));
+ } catch (err) {
+ // e.g. no crypto.subtle when the portal is not served over HTTPS
+ this.tokenError.set(`Could not start the sign-in (${(err as Error).message}).`);
+ this.creatingToken.set(false);
+ }
+ }
+
+ protected redirect(url: string): void {
+ window.location.assign(url);
+ }
signOut(): void {
this.authService.logout();
@@ -43,20 +79,14 @@ export class AccountPageComponent {
this.location.back();
}
- async copySetupSnippet(): Promise {
+ async copy(text: string, status: WritableSignal): Promise {
try {
- await navigator.clipboard.writeText(this.setupSnippet);
- this.showCopyStatus('Copied');
- } catch (err) {
- console.warn('Failed to copy setup snippet:', err);
- this.showCopyStatus('Could not copy, select the text instead');
+ await navigator.clipboard.writeText(text);
+ status.set('Copied');
+ } catch {
+ status.set('Could not copy, select the text instead');
}
- }
-
- // Cleared after a while so the live region changes, and is announced, on every click.
- private showCopyStatus(message: string): void {
- clearTimeout(this.copyStatusTimer);
- this.copyStatus.set(message);
- this.copyStatusTimer = setTimeout(() => this.copyStatus.set(''), 2400);
+ // Cleared after a while so the live region changes, and is announced, on every click.
+ setTimeout(() => status.set(''), 2400);
}
}
diff --git a/src/app/pages/account-page/cli-token.spec.ts b/src/app/pages/account-page/cli-token.spec.ts
new file mode 100644
index 00000000..7082a7ef
--- /dev/null
+++ b/src/app/pages/account-page/cli-token.spec.ts
@@ -0,0 +1,84 @@
+import { cliTokenSignInUrl, codeChallenge, finishCliTokenSignIn, isSignInReturn } from './cli-token';
+
+const config = { issuer: 'https://idp.example/realms/MIP', clientId: 'mip-cli-handson' };
+const redirectUri = 'https://portal.example/account';
+
+function jsonResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+}
+
+describe('cli-token', () => {
+ beforeEach(() => sessionStorage.clear());
+ afterEach(() => vi.restoreAllMocks());
+ afterAll(() => sessionStorage.clear());
+
+ it('derives the PKCE challenge as base64url(SHA-256(verifier)) without padding', async () => {
+ // Expected value computed independently with Python's hashlib.
+ expect(await codeChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFXmFDjwM')).toBe(
+ '4CLSnxkUKEUvyrSkXOVODl4ymFB08uOtK9zimkO4PAA',
+ );
+ });
+
+ it('starts an offline sign-in as the public client with PKCE', async () => {
+ const url = new URL(await cliTokenSignInUrl(config, redirectUri));
+
+ expect(url.origin + url.pathname).toBe('https://idp.example/realms/MIP/protocol/openid-connect/auth');
+ expect(url.searchParams.get('client_id')).toBe('mip-cli-handson');
+ expect(url.searchParams.get('response_type')).toBe('code');
+ expect(url.searchParams.get('scope')).toBe('openid offline_access');
+ expect(url.searchParams.get('redirect_uri')).toBe(redirectUri);
+ expect(url.searchParams.get('code_challenge_method')).toBe('S256');
+ expect(url.searchParams.get('code_challenge')).toMatch(/^[A-Za-z0-9_-]{43}$/);
+ expect(url.searchParams.get('state')).toBeTruthy();
+ expect(isSignInReturn(new URLSearchParams({ code: 'c', state: url.searchParams.get('state')! }))).toBe(true);
+ });
+
+ it('exchanges the code with the verifier and returns the refresh token', async () => {
+ const state = new URL(await cliTokenSignInUrl(config, redirectUri)).searchParams.get('state')!;
+ const fetchFn = vi.spyOn(window, 'fetch').mockResolvedValue(
+ jsonResponse(200, { access_token: 'a', refresh_token: 'offline-token' }),
+ );
+
+ const token = await finishCliTokenSignIn(new URLSearchParams({ code: 'the-code', state }), redirectUri);
+
+ expect(token).toBe('offline-token');
+ const [url, init] = fetchFn.mock.calls[0];
+ expect(url).toBe('https://idp.example/realms/MIP/protocol/openid-connect/token');
+ const body = new URLSearchParams(init!.body as URLSearchParams);
+ expect(body.get('grant_type')).toBe('authorization_code');
+ expect(body.get('code')).toBe('the-code');
+ expect(body.get('client_id')).toBe('mip-cli-handson');
+ expect(body.get('redirect_uri')).toBe(redirectUri);
+ expect(body.get('code_verifier')).toMatch(/^[A-Za-z0-9_-]{43}$/);
+ expect(sessionStorage.length).toBe(0);
+ });
+
+ it('refuses a return it did not start, and never sends the code', async () => {
+ await cliTokenSignInUrl(config, redirectUri);
+ const fetchFn = vi.spyOn(window, 'fetch');
+
+ await expect(
+ finishCliTokenSignIn(new URLSearchParams({ code: 'the-code', state: 'forged' }), redirectUri),
+ ).rejects.toThrow('not started on this page');
+ expect(fetchFn).not.toHaveBeenCalled();
+ expect(sessionStorage.length).toBe(0);
+ });
+
+ it('reports Keycloak refusals without the code', async () => {
+ const state = new URL(await cliTokenSignInUrl(config, redirectUri)).searchParams.get('state')!;
+ vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(400, { error: 'invalid_grant' }));
+
+ const failure = finishCliTokenSignIn(new URLSearchParams({ code: 'the-code', state }), redirectUri);
+
+ await expect(failure).rejects.toThrow('invalid_grant');
+ await expect(failure).rejects.not.toThrow('the-code');
+ });
+
+ it('reports a sign-in the user refused', async () => {
+ const state = new URL(await cliTokenSignInUrl(config, redirectUri)).searchParams.get('state')!;
+
+ await expect(
+ finishCliTokenSignIn(new URLSearchParams({ error: 'access_denied', state }), redirectUri),
+ ).rejects.toThrow('access_denied');
+ });
+});
diff --git a/src/app/pages/account-page/cli-token.ts b/src/app/pages/account-page/cli-token.ts
new file mode 100644
index 00000000..6c37444d
--- /dev/null
+++ b/src/app/pages/account-page/cli-token.ts
@@ -0,0 +1,85 @@
+// The token for the mip Python client, created on the account page: an OAuth 2.0
+// authorization-code sign-in with PKCE as the platform's public Keycloak client (from
+// GET /services/auth-config), asking for offline_access. Keycloak hands the long-lived
+// refresh token to the browser; the page shows it once and nothing stores it.
+
+export interface AuthConfig {
+ issuer: string;
+ clientId: string;
+}
+
+interface PendingSignIn extends AuthConfig {
+ state: string;
+ verifier: string;
+}
+
+const PENDING_KEY = 'mip-cli-token-sign-in';
+
+function base64Url(bytes: Uint8Array): string {
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+export async function codeChallenge(verifier: string): Promise {
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
+ return base64Url(new Uint8Array(digest));
+}
+
+function randomValue(): string {
+ return base64Url(crypto.getRandomValues(new Uint8Array(32)));
+}
+
+// Keycloak's endpoints under the issuer; its discovery document is not needed in the browser.
+function endpoint(issuer: string, name: 'auth' | 'token'): string {
+ return `${issuer.replace(/\/+$/, '')}/protocol/openid-connect/${name}`;
+}
+
+/** Where to send the browser to start the sign-in; remembers state and verifier for the return. */
+export async function cliTokenSignInUrl(config: AuthConfig, redirectUri: string): Promise {
+ const pending: PendingSignIn = { ...config, state: randomValue(), verifier: randomValue() };
+ sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending));
+ const params = new URLSearchParams({
+ client_id: config.clientId,
+ response_type: 'code',
+ scope: 'openid offline_access',
+ redirect_uri: redirectUri,
+ state: pending.state,
+ code_challenge: await codeChallenge(pending.verifier),
+ code_challenge_method: 'S256',
+ });
+ return `${endpoint(config.issuer, 'auth')}?${params}`;
+}
+
+/** True when this page load is an authorization response; finishCliTokenSignIn checks it is ours. */
+export function isSignInReturn(query: URLSearchParams): boolean {
+ return query.has('state') && (query.has('code') || query.has('error'));
+}
+
+/** Exchanges the returned code for the refresh token. Errors never include the code or token. */
+export async function finishCliTokenSignIn(query: URLSearchParams, redirectUri: string): Promise {
+ const raw = sessionStorage.getItem(PENDING_KEY);
+ sessionStorage.removeItem(PENDING_KEY);
+ const pending: PendingSignIn | null = raw ? JSON.parse(raw) : null;
+ if (!pending || query.get('state') !== pending.state) {
+ throw new Error('This sign-in was not started on this page. Create the token again.');
+ }
+ const error = query.get('error');
+ if (error) {
+ throw new Error(`Keycloak refused the sign-in (${error}).`);
+ }
+ const response = await fetch(endpoint(pending.issuer, 'token'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ grant_type: 'authorization_code',
+ code: query.get('code') ?? '',
+ redirect_uri: redirectUri,
+ client_id: pending.clientId,
+ code_verifier: pending.verifier,
+ }),
+ });
+ const body = await response.json().catch(() => ({}));
+ if (!response.ok || typeof body.refresh_token !== 'string') {
+ throw new Error(`Could not create the token (${body.error ?? response.status}).`);
+ }
+ return body.refresh_token;
+}