From 5a0cd009146fd8c88fcc052db4ed3ce21c604834 Mon Sep 17 00:00:00 2001 From: elf-pavlik Date: Thu, 22 Jan 2026 13:24:52 -0600 Subject: [PATCH 1/3] FedCM - a quick hack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: mrkvon Co-authored-by: Christopher Mühl Co-authored-by: bourgeoa --- src/core/AuthorizationCodeGrant.ts | 34 +++++++++++++++++++++++++++--- src/core/Session.ts | 7 +++--- src/web/Session.ts | 4 ++-- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/core/AuthorizationCodeGrant.ts b/src/core/AuthorizationCodeGrant.ts index a95bb67..e11a164 100644 --- a/src/core/AuthorizationCodeGrant.ts +++ b/src/core/AuthorizationCodeGrant.ts @@ -3,6 +3,12 @@ import { requestDynamicClientRegistration } from "./DynamicClientRegistration"; import { ClientDetails, DynamicRegistrationClientDetails, IdentityProviderDetails, SessionInformation, TokenDetails } from "./SessionInformation"; import { SessionDatabase } from "./SessionDatabase"; +// @ts-ignore +const buildRedirectUrl = (code, state, providerUrl) => { + const base = window.location.href; + return `${base}?code=${code}&state=${state}&iss=${encodeURIComponent(providerUrl)}`; +}; + /** * Login with the idp, using a provided `client_id` or dynamic client registration if none provided. * @@ -89,7 +95,30 @@ const redirectForLogin = async (idp: string, redirect_uri: string, client_detail `&state=${csrf_token}` + `&prompt=consent`; // this query parameter value MUST be present for CSS v7 to issue a refresh token ( // TODO open issue because prompting is the default behaviour but without this query param no refresh token is provided despite the "remember this client" box being checked) - window.location.href = redirect_to_idp; + // do FedCM dance 💃🏻 + // do check first! + const params = Object.fromEntries(new URL(redirect_to_idp).searchParams); + const credential = await navigator.credentials.get({ + // @ts-ignore + identity: { + providers: [{ + configURL: 'any', + clientId: params.client_id, + registered: true, + params: { + code_challenge: params.code_challenge, + code_challenge_method: params.code_challenge_method, + state: params.state + } + }] + } + }); + console.log(credential) + // XXX: we ♥️ trailing slash errors + // @ts-ignore + const fedCMissuer = new URL(credential.configURL).origin + '/' + // @ts-ignore + return buildRedirectUrl(credential.token, params.state, fedCMissuer) }; /** @@ -119,8 +148,7 @@ const getPKCEcode = async () => { * URL contains authrization code, issuer (idp) and state (csrf token), * get an access token for the authrization code. */ -const onIncomingRedirect = async (client_details?: ClientDetails, database?: SessionDatabase) => { - const url = new URL(window.location.href); +const onIncomingRedirect = async (url = new URL(window.location.href), client_details?: ClientDetails, database?: SessionDatabase) => { // authorization code const authorization_code = url.searchParams.get("code"); // if no code, session remains unauthenticated at this point diff --git a/src/core/Session.ts b/src/core/Session.ts index 7d1236a..1513beb 100644 --- a/src/core/Session.ts +++ b/src/core/Session.ts @@ -113,7 +113,8 @@ export class SessionCore extends EventTarget implements Session { } async login(idp: string, redirect_uri: string) { - await redirectForLogin(idp, redirect_uri, this.information.clientDetails) + const fedCMFakeUrl = await redirectForLogin(idp, redirect_uri, this.information.clientDetails) + await this.handleRedirectFromLogin(fedCMFakeUrl) } /** @@ -122,9 +123,9 @@ export class SessionCore extends EventTarget implements Session { * Upon success, it tries to persist information to refresh tokens in the session database. * If no database was provided, no information is persisted. */ - async handleRedirectFromLogin() { + async handleRedirectFromLogin(url?: string) { // Redirect after Authorization Code Grant // memory via sessionStorage - const newSessionInfo = await onIncomingRedirect(this.information.clientDetails, this.database); + const newSessionInfo = await onIncomingRedirect(url ? new URL(url) : undefined, this.information.clientDetails, this.database); // no session - we remain unauthenticated if (!newSessionInfo.tokenDetails) return; // we got a session diff --git a/src/web/Session.ts b/src/web/Session.ts index 338d2bd..703cfbb 100644 --- a/src/web/Session.ts +++ b/src/web/Session.ts @@ -74,8 +74,8 @@ export class WebWorkerSession extends SessionCore { }; - async handleRedirectFromLogin() { - await super.handleRedirectFromLogin(); + async handleRedirectFromLogin(url: string) { + await super.handleRedirectFromLogin(url); if (this.isActive) { // If login was successful, tell the worker to schedule refreshing this.worker.port.postMessage({ type: RefreshMessageTypes.SCHEDULE, From 6b9f44bfabd02f601ce9a56104869f7d99a9868f Mon Sep 17 00:00:00 2001 From: elf-pavlik Date: Thu, 22 Jan 2026 15:48:48 -0600 Subject: [PATCH 2/3] separate Session#fedCM method --- src/core/AuthorizationCodeGrant.ts | 97 ++++++++++++++++++------------ src/core/Session.ts | 9 ++- 2 files changed, 67 insertions(+), 39 deletions(-) diff --git a/src/core/AuthorizationCodeGrant.ts b/src/core/AuthorizationCodeGrant.ts index e11a164..85cfee6 100644 --- a/src/core/AuthorizationCodeGrant.ts +++ b/src/core/AuthorizationCodeGrant.ts @@ -9,18 +9,48 @@ const buildRedirectUrl = (code, state, providerUrl) => { return `${base}?code=${code}&state=${state}&iss=${encodeURIComponent(providerUrl)}`; }; -/** - * Login with the idp, using a provided `client_id` or dynamic client registration if none provided. - * - * @param idp - * @param redirect_uri - */ -const redirectForLogin = async (idp: string, redirect_uri: string, client_details?: ClientDetails) => { - // RFC 6749 - Section 3.1.2 - sanitize redirect_uri - const redirect_uri_ = new URL(redirect_uri); - const redirect_uri_sane = redirect_uri_.origin + redirect_uri_.pathname + redirect_uri_.search; - // lookup openid configuration of idp - const idp_origin = new URL(idp).origin; +const fedCMLogin = async (clientId: string): Promise => { + + // RFC 7636 PKCE, remember code verifer + const { pkce_code_verifier, pkce_code_challenge } = await getPKCEcode(); + sessionStorage.setItem("pkce_code_verifier", pkce_code_verifier); + + // RFC 6749 OAuth 2.0 - CSRF token + const csrf_token = window.crypto.randomUUID(); + sessionStorage.setItem("csrf_token", csrf_token); + + const credential = await navigator.credentials.get({ + // @ts-ignore + identity: { + providers: [{ + configURL: 'any', + clientId: clientId, + registered: true, + params: { + code_challenge: pkce_code_challenge, + code_challenge_method: 'S256', + state: csrf_token + } + }] + } + }); + + console.log('FedCM returned', credential) + + // @ts-ignore + const fedCMissuer = new URL(credential.configURL) + + // XXX: we ♥️ trailing slash errors + sessionStorage.setItem("idp", fedCMissuer.origin + '/'); + + await lookupIdp(fedCMissuer.origin + '/', fedCMissuer.origin) + + // XXX: figure out how to deal with state!!! + // @ts-ignore + return buildRedirectUrl(credential.token, csrf_token, fedCMissuer.origin + '/') +} + +const lookupIdp = async (idp: string, idp_origin: string) => { const openid_configuration = await fetch(`${idp_origin}/.well-known/openid-configuration`) .then((response) => { @@ -48,6 +78,22 @@ const redirectForLogin = async (idp: string, redirect_uri: string, client_detail "jwks_uri", openid_configuration["jwks_uri"] ); + return openid_configuration +} + +/** + * Login with the idp, using a provided `client_id` or dynamic client registration if none provided. + * + * @param idp + * @param redirect_uri + */ +const redirectForLogin = async (idp: string, redirect_uri: string, client_details?: ClientDetails) => { + // RFC 6749 - Section 3.1.2 - sanitize redirect_uri + const redirect_uri_ = new URL(redirect_uri); + const redirect_uri_sane = redirect_uri_.origin + redirect_uri_.pathname + redirect_uri_.search; + // lookup openid configuration of idp + const idp_origin = new URL(idp).origin; + const openid_configuration = await lookupIdp(idp, idp_origin) let client_id = client_details?.client_id; // no client_id => attempt dynamic registration @@ -95,30 +141,7 @@ const redirectForLogin = async (idp: string, redirect_uri: string, client_detail `&state=${csrf_token}` + `&prompt=consent`; // this query parameter value MUST be present for CSS v7 to issue a refresh token ( // TODO open issue because prompting is the default behaviour but without this query param no refresh token is provided despite the "remember this client" box being checked) - // do FedCM dance 💃🏻 - // do check first! - const params = Object.fromEntries(new URL(redirect_to_idp).searchParams); - const credential = await navigator.credentials.get({ - // @ts-ignore - identity: { - providers: [{ - configURL: 'any', - clientId: params.client_id, - registered: true, - params: { - code_challenge: params.code_challenge, - code_challenge_method: params.code_challenge_method, - state: params.state - } - }] - } - }); - console.log(credential) - // XXX: we ♥️ trailing slash errors - // @ts-ignore - const fedCMissuer = new URL(credential.configURL).origin + '/' - // @ts-ignore - return buildRedirectUrl(credential.token, params.state, fedCMissuer) + window.location.href = redirect_to_idp; }; /** @@ -331,4 +354,4 @@ const requestAccessToken = async ( }); }; -export { redirectForLogin, onIncomingRedirect }; +export { redirectForLogin, fedCMLogin, onIncomingRedirect }; diff --git a/src/core/Session.ts b/src/core/Session.ts index 1513beb..60561e0 100644 --- a/src/core/Session.ts +++ b/src/core/Session.ts @@ -1,5 +1,5 @@ import { SignJWT, decodeJwt, exportJWK } from "jose"; -import { redirectForLogin, onIncomingRedirect } from "./AuthorizationCodeGrant"; +import { redirectForLogin, fedCMLogin, onIncomingRedirect } from "./AuthorizationCodeGrant"; import { renewTokens } from "./RefreshTokenGrant"; import { SessionDatabase } from "./SessionDatabase"; import { DynamicRegistrationClientDetails, DereferencableIdClientDetails, SessionInformation, TokenDetails } from "./SessionInformation"; @@ -113,7 +113,12 @@ export class SessionCore extends EventTarget implements Session { } async login(idp: string, redirect_uri: string) { - const fedCMFakeUrl = await redirectForLogin(idp, redirect_uri, this.information.clientDetails) + await redirectForLogin(idp, redirect_uri, this.information.clientDetails) + } + + async fedCM() { + if (!this.information.clientDetails.client_id) throw new Error('FedCM requires Client ID URL') + const fedCMFakeUrl = await fedCMLogin(this.information.clientDetails.client_id) await this.handleRedirectFromLogin(fedCMFakeUrl) } From ccd2f0c0b7bb84cca61a4f440586da20b4557c05 Mon Sep 17 00:00:00 2001 From: elf-pavlik Date: Thu, 22 Jan 2026 17:37:44 -0600 Subject: [PATCH 3/3] separate FedCMLogin --- src/core/AuthorizationCodeGrant.ts | 100 ++++++++++++++++++++--------- src/core/Session.ts | 22 ++++--- src/web/Session.ts | 6 +- 3 files changed, 84 insertions(+), 44 deletions(-) diff --git a/src/core/AuthorizationCodeGrant.ts b/src/core/AuthorizationCodeGrant.ts index 85cfee6..ae3f493 100644 --- a/src/core/AuthorizationCodeGrant.ts +++ b/src/core/AuthorizationCodeGrant.ts @@ -3,13 +3,13 @@ import { requestDynamicClientRegistration } from "./DynamicClientRegistration"; import { ClientDetails, DynamicRegistrationClientDetails, IdentityProviderDetails, SessionInformation, TokenDetails } from "./SessionInformation"; import { SessionDatabase } from "./SessionDatabase"; -// @ts-ignore -const buildRedirectUrl = (code, state, providerUrl) => { - const base = window.location.href; - return `${base}?code=${code}&state=${state}&iss=${encodeURIComponent(providerUrl)}`; -}; +type FedCMData = { + token: string + configURL: string +} -const fedCMLogin = async (clientId: string): Promise => { +const fedCMLogin = async (client_details: ClientDetails, database?: SessionDatabase): Promise => { + if (!client_details.client_id) throw new Error('FedCM requires Client ID URL') // RFC 7636 PKCE, remember code verifer const { pkce_code_verifier, pkce_code_challenge } = await getPKCEcode(); @@ -24,30 +24,31 @@ const fedCMLogin = async (clientId: string): Promise => { identity: { providers: [{ configURL: 'any', - clientId: clientId, + clientId: client_details.client_id, registered: true, params: { code_challenge: pkce_code_challenge, code_challenge_method: 'S256', + // TODO: test without state: csrf_token } }] } - }); + }) as unknown as FedCMData; - console.log('FedCM returned', credential) - - // @ts-ignore - const fedCMissuer = new URL(credential.configURL) + const fedCMissuer = new URL(credential.configURL).origin // XXX: we ♥️ trailing slash errors - sessionStorage.setItem("idp", fedCMissuer.origin + '/'); - - await lookupIdp(fedCMissuer.origin + '/', fedCMissuer.origin) + await lookupIdp(fedCMissuer + '/', fedCMissuer) - // XXX: figure out how to deal with state!!! - // @ts-ignore - return buildRedirectUrl(credential.token, csrf_token, fedCMissuer.origin + '/') + // XXX: figure out how to deal with state check!!! + // XXX: figure out how to deal issuer check!!! + return completeFlow({ + authorization_code: credential.token, + idp: fedCMissuer + '/', + client_details, + database + }) } const lookupIdp = async (idp: string, idp_origin: string) => { @@ -171,7 +172,8 @@ const getPKCEcode = async () => { * URL contains authrization code, issuer (idp) and state (csrf token), * get an access token for the authrization code. */ -const onIncomingRedirect = async (url = new URL(window.location.href), client_details?: ClientDetails, database?: SessionDatabase) => { +const onIncomingRedirect = async (client_details?: ClientDetails, database?: SessionDatabase) => { + const url = new URL(window.location.href) // authorization code const authorization_code = url.searchParams.get("code"); // if no code, session remains unauthenticated at this point @@ -197,6 +199,32 @@ const onIncomingRedirect = async (url = new URL(window.location.href), client_de url.searchParams.delete("code"); window.history.pushState({}, document.title, url.toString()); + const redirect_url = url.toString() + + return completeFlow({ + client_details, + redirect_url, + authorization_code, + idp, + database + }) +}; + +type FlowData = { + client_details?: ClientDetails + redirect_url?: string + authorization_code: string + idp: string + database?: SessionDatabase +} + +const completeFlow = async ({ + client_details, + redirect_url, + authorization_code, + idp, + database +}: FlowData) => { // prepare token request const pkce_code_verifier = sessionStorage.getItem("pkce_code_verifier"); if (pkce_code_verifier === null) { @@ -224,10 +252,10 @@ const onIncomingRedirect = async (url = new URL(window.location.href), client_de await requestAccessToken( authorization_code, pkce_code_verifier, - url.toString(), client_id, token_endpoint, - key_pair + key_pair, + redirect_url ) .then((response) => { if (!response.ok) { @@ -267,7 +295,9 @@ const onIncomingRedirect = async (url = new URL(window.location.href), client_de // summarise session info const token_details = { ...token_response, dpop_key_pair: key_pair } as TokenDetails; const idp_details = { idp, jwks_uri, token_endpoint } as IdentityProviderDetails - if (!client_details) client_details = { redirect_uris: [url.toString()] }; + + // XXX: figure out for FedCM + if (!client_details) client_details = { redirect_uris: [redirect_url!] }; client_details.client_id = client_id; // and persist refresh token details @@ -298,7 +328,8 @@ const onIncomingRedirect = async (url = new URL(window.location.href), client_de idpDetails: idp_details, tokenDetails: token_details } as SessionInformation -}; + +} /** @@ -314,10 +345,10 @@ const onIncomingRedirect = async (url = new URL(window.location.href), client_de const requestAccessToken = async ( authorization_code: string, pkce_code_verifier: string, - redirect_uri: string, client_id: string, token_endpoint: string, - key_pair: GenerateKeyPairResult + key_pair: GenerateKeyPairResult, + redirect_uri?: string, ) => { // prepare public key to bind access token to const jwk_public_key = await exportJWK(key_pair.publicKey); @@ -336,6 +367,17 @@ const requestAccessToken = async ( }) .sign(key_pair.privateKey); + const params = { + grant_type: "authorization_code", + code: authorization_code, + code_verifier: pkce_code_verifier, + client_id: client_id, + } + + // FedCM doesn't use redirects + // @ts-ignore + if (redirect_uri) params.redirect_uri = redirect_uri + return fetch( token_endpoint, { @@ -344,13 +386,7 @@ const requestAccessToken = async ( dpop, "Content-Type": "application/x-www-form-urlencoded", }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code: authorization_code, - code_verifier: pkce_code_verifier, - redirect_uri: redirect_uri, - client_id: client_id, - }), + body: new URLSearchParams(params), }); }; diff --git a/src/core/Session.ts b/src/core/Session.ts index 60561e0..69e73e3 100644 --- a/src/core/Session.ts +++ b/src/core/Session.ts @@ -117,9 +117,8 @@ export class SessionCore extends EventTarget implements Session { } async fedCM() { - if (!this.information.clientDetails.client_id) throw new Error('FedCM requires Client ID URL') - const fedCMFakeUrl = await fedCMLogin(this.information.clientDetails.client_id) - await this.handleRedirectFromLogin(fedCMFakeUrl) + const newSessionInfo = await fedCMLogin(this.information.clientDetails, this.database) + await this.handleLogin(newSessionInfo) } /** @@ -128,17 +127,22 @@ export class SessionCore extends EventTarget implements Session { * Upon success, it tries to persist information to refresh tokens in the session database. * If no database was provided, no information is persisted. */ - async handleRedirectFromLogin(url?: string) { + async handleRedirectFromLogin() { // Redirect after Authorization Code Grant // memory via sessionStorage - const newSessionInfo = await onIncomingRedirect(url ? new URL(url) : undefined, this.information.clientDetails, this.database); + const newSessionInfo = await onIncomingRedirect(this.information.clientDetails, this.database); + await this.handleLogin(newSessionInfo) + } + + async handleLogin(sessionInfo: SessionInformation) { // no session - we remain unauthenticated - if (!newSessionInfo.tokenDetails) return; + if (!sessionInfo.tokenDetails) return; // we got a session - this.information.clientDetails = newSessionInfo.clientDetails - this.information.idpDetails = newSessionInfo.idpDetails; - await this.setTokenDetails(newSessionInfo.tokenDetails) + this.information.clientDetails = sessionInfo.clientDetails + this.information.idpDetails = sessionInfo.idpDetails; + await this.setTokenDetails(sessionInfo.tokenDetails) // callback state change this.dispatchStateChangeEvent(); // we logged in + } /** diff --git a/src/web/Session.ts b/src/web/Session.ts index 703cfbb..b2a0fd5 100644 --- a/src/web/Session.ts +++ b/src/web/Session.ts @@ -74,8 +74,8 @@ export class WebWorkerSession extends SessionCore { }; - async handleRedirectFromLogin(url: string) { - await super.handleRedirectFromLogin(url); + async handleRedirectFromLogin() { + await super.handleRedirectFromLogin(); if (this.isActive) { // If login was successful, tell the worker to schedule refreshing this.worker.port.postMessage({ type: RefreshMessageTypes.SCHEDULE, @@ -101,4 +101,4 @@ export class WebWorkerSession extends SessionCore { await super.logout(); } -} \ No newline at end of file +}