From a98c57d3bc4e4ddff893e0d028b9d4a6f461f0c8 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 23 Oct 2026 17:16:53 +0300 Subject: [PATCH 1/2] feat(account): create a token for the mip client with a Keycloak sign-in The "Use MIP from your own computer" guide now shows only when /services/auth-config names the federation's public Keycloak client, and no longer sends users to the JupyterHub token page. "Create access token" runs an authorization-code sign-in with PKCE as that client (offline_access, in cli-token.ts), takes the one-time code out of the address bar, and shows the token once for `mip login --token` or MIP_API_TOKEN; nothing stores it. The setup snippet only installs the client and sets MIP_URL; the two ways to sign in (access token, `mip login`) sit side by side in cards, and the guide says a sign-in lasts at most 6 months, ends after 30 days without use, and links to the Keycloak account page where it can be ended everywhere. --- .../account-page/account-page.component.css | 119 ++++++++++---- .../account-page/account-page.component.html | 95 ++++++++---- .../account-page.component.spec.ts | 146 +++++++++++++++--- .../account-page/account-page.component.ts | 88 +++++++---- src/app/pages/account-page/cli-token.spec.ts | 84 ++++++++++ src/app/pages/account-page/cli-token.ts | 85 ++++++++++ 6 files changed, 508 insertions(+), 109 deletions(-) create mode 100644 src/app/pages/account-page/cli-token.spec.ts create mode 100644 src/app/pages/account-page/cli-token.ts 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

-
    -
  1. - + +

    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()) { + + } + @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.

    -
  2. -
  3. - 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()
    -
  4. -
+

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 + : 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; +} From cecae02629ec93393efb09c5b6760a769d40e851 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Fri, 23 Oct 2026 17:16:53 +0300 Subject: [PATCH 2/2] docs(auth): describe remote use with a Keycloak token for the mip client --- docs/llm-wiki/08-auth-and-terms-flow.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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`.