Google OIDC provider: link-only, sub-keyed, PKCE and nonce #2
28 changed files with 1513 additions and 68 deletions
|
|
@ -14,7 +14,7 @@ The point is painfully simple:
|
|||
|
||||
## Front Door
|
||||
|
||||
- **Value:** one shared auth authority: provider OAuth (Discord, Patreon, GitHub, Twitch, YouTube, Spotify), linked identities, grants, entitlements and session issuance, with Ed25519-signed claims that app backends verify locally.
|
||||
- **Value:** one shared auth authority: provider OAuth (Discord, Patreon, GitHub, Twitch, YouTube, Spotify, Google OIDC for linking), linked identities, grants, entitlements and session issuance, with Ed25519-signed claims that app backends verify locally.
|
||||
- **Problem:** every GameCult app was rebuilding Discord/Patreon login glue. Heimdall centralizes identity authority and keeps app-domain data in the apps.
|
||||
- **Entry points:**
|
||||
- `src/index.ts` and `src/app.ts`: the Fastify service. `src/signing.ts` and `src/claims.ts` issue JWTs and serve `/.well-known/jwks.json`; `src/oauth.ts` and `src/providers.ts` own provider flows.
|
||||
|
|
|
|||
|
|
@ -157,6 +157,8 @@ optional_environment = [
|
|||
"GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID",
|
||||
"GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID",
|
||||
"GC_ACCESS_PROVIDER_GOOGLE_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_ID",
|
||||
"GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_SECRET_FILE",
|
||||
"GC_ACCESS_PROVIDER_PATREON_CLIENT_ID",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ Supported providers in the skeleton:
|
|||
- `youtube`
|
||||
- `spotify`
|
||||
- `joystick`
|
||||
- `google`
|
||||
|
||||
Request body:
|
||||
|
||||
|
|
@ -179,6 +180,44 @@ Launch note:
|
|||
implemented yet
|
||||
- Repixelizer launch can configure Discord and Patreon on its provider buttons
|
||||
|
||||
### Google (OpenID Connect, link-only)
|
||||
|
||||
`google` is an identity provider that never mints an account. It is a second
|
||||
door into a person who already has one.
|
||||
|
||||
- Callback redirect URI (register exactly this in the Google Cloud OAuth
|
||||
client): `{GC_ACCESS_BASE_URL}/v1/oauth/google/callback`, which is
|
||||
`https://heimdall.gamecult.org/v1/oauth/google/callback` in production.
|
||||
- Scopes `openid email profile`. The authorization request carries `state`
|
||||
(signed, Heimdall's), a `nonce`, and a PKCE `S256` challenge. The verifier
|
||||
and the nonce are derived from the state's `jti` and Heimdall's signing key,
|
||||
so nothing is stored between start and callback.
|
||||
- The callback verifies the `id_token` (RS256 against Google's published
|
||||
keys; `iss` is Google, `aud` is this client, `exp`, `iat`, `nonce`) and
|
||||
requires a `sub` and a verified email. The account key is `sub`; the email
|
||||
is a display and contact field and never a lookup key.
|
||||
- `mode: "link"` needs `Authorization: Bearer <Heimdall access token>` for the
|
||||
same app; the identity attaches to that token's account and to no other.
|
||||
`mode: "sign_in"` signs in an account the `sub` is already linked to.
|
||||
- A `sub` no account holds, arriving with `sign_in`, is refused and mints
|
||||
nothing. A `sub` linked to another account is refused and never moved.
|
||||
Both audit as `oauth_callback_failed` with error class `LinkRefusal`
|
||||
and the code `identity_not_linked` or `link_initiator_mismatch`.
|
||||
- Link is two-phase. The callback verifies the identity and records a pending
|
||||
link (TTL `completionTtlSeconds`), answering `status: "link_pending"` with a
|
||||
`pendingLinkId` and no session; it writes no linked identity. The app calls
|
||||
`POST /v1/apps/:appSlug/links/confirm` with `{ pendingLinkId }` and the
|
||||
Heimdall access token of the account that began the link; only that call
|
||||
attaches the identity. A different account, an expired, used or unknown id
|
||||
answers 410; a `sub` another account holds by then answers 409. A refused
|
||||
confirm does not consume the link.
|
||||
- A sign-in callback evaluates every linked identity of the account with the
|
||||
start-time entitlement policy, so a member returning through Google keeps
|
||||
what Discord or Patreon grant.
|
||||
- Configure with `GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID` and
|
||||
`GC_ACCESS_PROVIDER_GOOGLE_CLIENT_SECRET_FILE`. Admitted to the `bifrost`
|
||||
app profile only.
|
||||
|
||||
### `GET /v1/oauth/{provider}/callback`
|
||||
|
||||
Purpose:
|
||||
|
|
|
|||
|
|
@ -137,8 +137,8 @@ const streampixelsProfile: AppProfile = {
|
|||
const bifrostProfile: AppProfile = {
|
||||
slug: "bifrost",
|
||||
displayName: "Bifrost",
|
||||
profileVersion: "2026-06-08",
|
||||
identityProviders: ["discord", "patreon"],
|
||||
profileVersion: "2026-11-04",
|
||||
identityProviders: ["discord", "patreon", "google"],
|
||||
entitlementSources: ["discord", "patreon"],
|
||||
managedConnectionProviders: [],
|
||||
allowedReturnOrigins: ["https://bifrost.gamecult.org"],
|
||||
|
|
|
|||
337
src/app.ts
337
src/app.ts
|
|
@ -28,6 +28,7 @@ import { renderBrowserHandoffPage } from "./browser-handoff.js";
|
|||
import { type HeimdallConfig, loadConfig } from "./config.js";
|
||||
import { createTokenCustody, type TokenCustody } from "./custody.js";
|
||||
import { grantFact } from "./facts.js";
|
||||
import { deriveOidcSecrets } from "./google-oidc.js";
|
||||
import {
|
||||
createOAuthRuntimeRegistry,
|
||||
fetchPatreonIdentity,
|
||||
|
|
@ -95,7 +96,7 @@ function buildOAuthStateToken(options: {
|
|||
handoff: OAuthHandoff;
|
||||
entitlementPolicy: OAuthEntitlementPolicy | undefined;
|
||||
keys: ReturnType<typeof createRuntimeKeyMaterial>;
|
||||
}): { token: string; expiresAt: string } {
|
||||
}): { token: string; jti: string; expiresAt: string } {
|
||||
const issuedAt = nowEpochSeconds();
|
||||
const expiresAtEpoch = issuedAt + options.config.stateTtlSeconds;
|
||||
const payload: OAuthStatePayload = {
|
||||
|
|
@ -120,6 +121,7 @@ function buildOAuthStateToken(options: {
|
|||
|
||||
return {
|
||||
token,
|
||||
jti: payload.jti,
|
||||
expiresAt: new Date(expiresAtEpoch * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
|
@ -254,18 +256,14 @@ function relayedProviderText(value: string | undefined, bound: number): string |
|
|||
return printable ? printable : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* What may be recorded or answered about a caught error: its class and its
|
||||
* code, each only when it is a short identifier. Never its message, which
|
||||
* carries whatever the thrower quoted (a host, a database, a provider's body).
|
||||
*/
|
||||
/** A link callback the signed state's initiator does not license. Its message is static and safe to relay. */
|
||||
class LinkRefusal extends Error {
|
||||
export class LinkRefusal extends Error {
|
||||
constructor(
|
||||
readonly code: "link_initiator_missing" | "link_initiator_mismatch" | "link_initiator_unknown",
|
||||
readonly code: "link_initiator_missing" | "link_initiator_mismatch" | "link_initiator_unknown" | "identity_not_linked",
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "LinkRefusal";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -408,6 +406,8 @@ async function evaluateStoredEntitlements(options: {
|
|||
appSlug: AppSlug;
|
||||
accountId: string;
|
||||
entitlementPolicies: OAuthEntitlementPolicy[];
|
||||
/** A provider the caller has already evaluated with fresh tokens. */
|
||||
skipProvider?: Provider;
|
||||
}): Promise<string[]> {
|
||||
const profile = getAppProfile(options.appSlug);
|
||||
if (!profile) {
|
||||
|
|
@ -417,7 +417,7 @@ async function evaluateStoredEntitlements(options: {
|
|||
const facts: string[] = [];
|
||||
|
||||
for (const storedIdentity of linkedIdentities) {
|
||||
if (!profile.entitlementSources.includes(storedIdentity.provider)) {
|
||||
if (!profile.entitlementSources.includes(storedIdentity.provider) || storedIdentity.provider === options.skipProvider) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -502,6 +502,13 @@ export async function startOAuthFlow(
|
|||
* which one reaches the signed state token.
|
||||
*/
|
||||
trustedBrowserAttemptId?: string;
|
||||
/**
|
||||
* The account an explicit link binds to. Like `trustedBrowserAttemptId`
|
||||
* it is a function argument, never a value parsed out of the request:
|
||||
* the HTTP route sets it only from a verified Heimdall access token for
|
||||
* this app (`resolveLinkAccount`), so a caller cannot name an account.
|
||||
*/
|
||||
linkAccountId?: string;
|
||||
}
|
||||
): Promise<HandlerResult> {
|
||||
const { config, keys } = ctx;
|
||||
|
|
@ -537,19 +544,43 @@ export async function startOAuthFlow(
|
|||
};
|
||||
}
|
||||
|
||||
const descriptor = providerCatalog[provider];
|
||||
// A provider that never mints an account attaches an identity only through
|
||||
// a bound link: the user's own access token, since the app's word does not
|
||||
// name this person to Google. A link start without a verified Heimdall session would end
|
||||
// in a refusal at the callback, so it is refused before the redirect.
|
||||
// `sign_in` stays open: it signs in an identity already linked, and the
|
||||
// callback refuses one that is not.
|
||||
if (descriptor.firstLogin === "requires_link" && input.mode === "link" && !options?.linkAccountId) {
|
||||
return {
|
||||
statusCode: 401,
|
||||
body: {
|
||||
error: "link_session_required",
|
||||
detail: `Linking ${descriptor.displayName} needs a Heimdall access token for ${profile.slug}: send it as a Bearer token.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Link mode binds an identity to an existing account, so the account must
|
||||
// be named by someone Heimdall can believe: the authenticated app backend.
|
||||
// The name rides in the signed state and the callback holds the provider's
|
||||
// identity to it. No other mode carries an initiator.
|
||||
if (input.mode === "link") {
|
||||
if (!isAppCaller(caller) || caller.appSlug !== profile.slug) {
|
||||
return { statusCode: 401, body: { error: "app_auth_required" } };
|
||||
}
|
||||
if (!input.initiatingAccountId) {
|
||||
return { statusCode: 400, body: { error: "link_initiator_required", detail: "link mode needs initiatingAccountId." } };
|
||||
}
|
||||
if (!(await ctx.store.findAccountById(input.initiatingAccountId))) {
|
||||
return { statusCode: 400, body: { error: "link_initiator_unknown", detail: "initiatingAccountId names no account." } };
|
||||
if (options?.linkAccountId) {
|
||||
// The user's own access token for this app names the account.
|
||||
if (input.initiatingAccountId !== undefined && input.initiatingAccountId !== options.linkAccountId) {
|
||||
return { statusCode: 400, body: { error: "link_initiator_conflict", detail: "initiatingAccountId differs from the bearer's account." } };
|
||||
}
|
||||
} else {
|
||||
if (!isAppCaller(caller) || caller.appSlug !== profile.slug) {
|
||||
return { statusCode: 401, body: { error: "app_auth_required" } };
|
||||
}
|
||||
if (!input.initiatingAccountId) {
|
||||
return { statusCode: 400, body: { error: "link_initiator_required", detail: "link mode needs initiatingAccountId." } };
|
||||
}
|
||||
if (!(await ctx.store.findAccountById(input.initiatingAccountId))) {
|
||||
return { statusCode: 400, body: { error: "link_initiator_unknown", detail: "initiatingAccountId names no account." } };
|
||||
}
|
||||
}
|
||||
} else if (input.initiatingAccountId !== undefined) {
|
||||
return { statusCode: 400, body: { error: "initiator_only_in_link_mode" } };
|
||||
|
|
@ -618,11 +649,12 @@ export async function startOAuthFlow(
|
|||
mode: input.mode,
|
||||
returnTo: input.returnTo,
|
||||
connection: input.connection,
|
||||
initiatorAccountId: input.initiatingAccountId,
|
||||
initiatorAccountId: options?.linkAccountId ?? input.initiatingAccountId,
|
||||
handoff,
|
||||
entitlementPolicy,
|
||||
keys,
|
||||
});
|
||||
const oidc = descriptor.oidc ? deriveOidcSecrets(state.jti, keys.privateKey) : undefined;
|
||||
|
||||
return {
|
||||
statusCode: 201,
|
||||
|
|
@ -637,6 +669,7 @@ export async function startOAuthFlow(
|
|||
redirectUri: callbackUrl,
|
||||
state: state.token,
|
||||
requestedScopes: input.requestedScopes,
|
||||
...(oidc ? { oidc: { nonce: oidc.nonce, codeChallenge: oidc.codeChallenge } } : {}),
|
||||
}),
|
||||
handoff,
|
||||
stateToken: state.token,
|
||||
|
|
@ -645,6 +678,49 @@ export async function startOAuthFlow(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The account a link start binds to: the one a live Heimdall access token for
|
||||
* this app names. The token must verify under Heimdall's own key, be an access
|
||||
* token for `appSlug`, be inside its validity window, and belong to a session
|
||||
* the store still holds for that account at that revision. A bearer that fails
|
||||
* any of these binds nothing: a link is explicit or it does not happen.
|
||||
*/
|
||||
async function resolveLinkAccount(
|
||||
ctx: Pick<HeimdallRuntimeContext, "config" | "keys" | "store">,
|
||||
appSlug: AppSlug,
|
||||
authorization: string | undefined
|
||||
): Promise<string | null> {
|
||||
const token = /^Bearer\s+(\S+)$/i.exec(authorization ?? "")?.[1];
|
||||
if (!token) return null;
|
||||
const verification = verifyJwt(token, ctx.keys.publicKey);
|
||||
if (!verification.valid) return null;
|
||||
const claims = verification.payload;
|
||||
const now = nowEpochSeconds();
|
||||
if (
|
||||
claims.typ !== "heimdall_access" ||
|
||||
claims.iss !== ctx.config.issuer ||
|
||||
claims.aud !== appSlug ||
|
||||
typeof claims.account_id !== "string" ||
|
||||
typeof claims.sid !== "string" ||
|
||||
typeof claims.access_revision !== "number" ||
|
||||
typeof claims.exp !== "number" ||
|
||||
claims.exp <= now ||
|
||||
(typeof claims.nbf === "number" && claims.nbf > now)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const session = await ctx.store.findSession(appSlug, claims.sid);
|
||||
if (
|
||||
!session ||
|
||||
session.accountId !== claims.account_id ||
|
||||
session.accessRevision !== claims.access_revision ||
|
||||
Date.parse(session.expiresAt) <= Date.now()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return claims.account_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session refresh, extracted for the same reason as startOAuthFlow: the HTTP
|
||||
* route and the plane's `refreshAuth` (which owns Ghostlight identity) call
|
||||
|
|
@ -912,7 +988,18 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
},
|
||||
async (request, reply) => {
|
||||
const caller = resolveAppCaller(config, request.body.appSlug, request.headers);
|
||||
const result = await startOAuthFlow({ config, keys, store }, request.params.provider, caller, request.body);
|
||||
let linkAccountId: string | undefined;
|
||||
if (request.body.mode === "link") {
|
||||
const bound = await resolveLinkAccount({ config, keys, store }, request.body.appSlug, request.headers.authorization);
|
||||
if (bound === null && request.headers.authorization !== undefined) {
|
||||
reply.code(401);
|
||||
return { error: "invalid_link_session", detail: "The bearer is not a live Heimdall access token for this app." };
|
||||
}
|
||||
linkAccountId = bound ?? undefined;
|
||||
}
|
||||
const result = await startOAuthFlow({ config, keys, store }, request.params.provider, caller, request.body, {
|
||||
...(linkAccountId !== undefined ? { linkAccountId } : {}),
|
||||
});
|
||||
reply.code(result.statusCode);
|
||||
return result.body;
|
||||
}
|
||||
|
|
@ -1069,35 +1156,136 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
if (mint === "refused") {
|
||||
throw new Error("The signed state names a mode the provider has no role for in this app.");
|
||||
}
|
||||
const callbackDescriptor = providerCatalog[request.params.provider];
|
||||
const oidc = callbackDescriptor.oidc ? deriveOidcSecrets(statePayload.jti, keys.privateKey) : undefined;
|
||||
const tokenSet = await runtime.exchangeAuthorizationCode({
|
||||
config,
|
||||
code: request.query.code,
|
||||
redirectUri: callbackUrl,
|
||||
...(oidc ? { codeVerifier: oidc.codeVerifier } : {}),
|
||||
});
|
||||
const identity = await runtime.resolveIdentity({
|
||||
config,
|
||||
accessToken: tokenSet.accessToken,
|
||||
tokenSet,
|
||||
...(oidc ? { oidcNonce: oidc.nonce } : {}),
|
||||
});
|
||||
const nowIso = new Date().toISOString();
|
||||
let account = await store.findAccountByLinkedIdentity(identity.provider, identity.providerUserId);
|
||||
|
||||
// Link mode attaches the provider identity to the account the app
|
||||
// named at start, and to no other: an identity already held by
|
||||
// another account is a refusal, never a quiet sign-in as that account.
|
||||
const linkInitiatorId = statePayload.mode === "link" ? statePayload.initiator_account_id : null;
|
||||
// One person, one account. A link attaches the provider identity to the
|
||||
// account the signed state names, and to no other: an identity another
|
||||
// account holds is a refusal, never a quiet sign-in as that account or a
|
||||
// move. A provider that never mints an account refuses an identity no
|
||||
// account holds.
|
||||
const boundAccountId = statePayload.mode === "link" ? statePayload.initiator_account_id : null;
|
||||
if (statePayload.mode === "link") {
|
||||
if (!linkInitiatorId) {
|
||||
if (!boundAccountId) {
|
||||
throw new LinkRefusal("link_initiator_missing", "The signed link state names no initiating account.");
|
||||
}
|
||||
if (account && account.id !== linkInitiatorId) {
|
||||
if (account && account.id !== boundAccountId) {
|
||||
throw new LinkRefusal("link_initiator_mismatch", "This identity belongs to a different account than the one that started the link.");
|
||||
}
|
||||
if (!account) {
|
||||
account = await store.findAccountById(linkInitiatorId);
|
||||
account = await store.findAccountById(boundAccountId);
|
||||
if (!account) {
|
||||
throw new LinkRefusal("link_initiator_unknown", "The initiating account no longer exists.");
|
||||
}
|
||||
}
|
||||
} else if (!account && callbackDescriptor.firstLogin === "requires_link") {
|
||||
throw new LinkRefusal("identity_not_linked", "No account holds this identity; sign in another way and link it first.");
|
||||
}
|
||||
|
||||
const buildLinkedIdentityInput = (accountId: string): UpsertLinkedIdentityInput => {
|
||||
const linkedIdentityInput: UpsertLinkedIdentityInput = {
|
||||
accountId,
|
||||
provider: identity.provider,
|
||||
providerUserId: identity.providerUserId,
|
||||
scopes: tokenSet.scope,
|
||||
profileJson: identity.profile,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
if (identity.username !== undefined) {
|
||||
linkedIdentityInput.username = identity.username;
|
||||
}
|
||||
if (identity.displayName !== undefined) {
|
||||
linkedIdentityInput.displayName = identity.displayName;
|
||||
}
|
||||
if (identity.primaryEmail !== undefined) {
|
||||
linkedIdentityInput.primaryEmail = identity.primaryEmail;
|
||||
}
|
||||
linkedIdentityInput.accessTokenEncrypted = tokenCustody.encrypt(tokenSet.accessToken);
|
||||
if (tokenSet.refreshToken !== undefined) {
|
||||
linkedIdentityInput.refreshTokenEncrypted = tokenCustody.encrypt(tokenSet.refreshToken);
|
||||
}
|
||||
if (tokenSet.expiresAt !== undefined) {
|
||||
linkedIdentityInput.tokenExpiresAt = tokenSet.expiresAt;
|
||||
}
|
||||
return linkedIdentityInput;
|
||||
};
|
||||
|
||||
// A link is two-phase. The callback cannot tie itself to the browser
|
||||
// that began the link (start is a server-to-server POST), so whoever
|
||||
// completes the Google login here may be someone the initiator sent
|
||||
// the URL to. Nothing is written to the account: the verified identity
|
||||
// waits as a pending link, and only a confirm bearing the access token
|
||||
// of the account that began the link attaches it.
|
||||
if (boundAccountId && callbackDescriptor.firstLogin === "requires_link") {
|
||||
const pendingLink = await store.createPendingLink({
|
||||
appSlug: statePayload.app_slug,
|
||||
accountId: boundAccountId,
|
||||
provider: identity.provider,
|
||||
providerUserId: identity.providerUserId,
|
||||
identity: buildLinkedIdentityInput(boundAccountId),
|
||||
returnTo: statePayload.return_to,
|
||||
createdAt: nowIso,
|
||||
expiresAt: new Date(Date.now() + config.completionTtlSeconds * 1000).toISOString(),
|
||||
});
|
||||
await store.createAuditEvent({
|
||||
accountId: boundAccountId,
|
||||
appSlug: statePayload.app_slug,
|
||||
eventType: "link_pending",
|
||||
// Never the pending link's id: it is the confirm secret.
|
||||
eventPayloadJson: { provider: identity.provider, expiresAt: pendingLink.expiresAt },
|
||||
createdAt: nowIso,
|
||||
});
|
||||
const pendingBody = {
|
||||
status: "link_pending" as const,
|
||||
provider: request.params.provider,
|
||||
mode: statePayload.mode,
|
||||
appSlug: statePayload.app_slug,
|
||||
returnTo: statePayload.return_to,
|
||||
pendingLinkId: pendingLink.id,
|
||||
expiresAt: pendingLink.expiresAt,
|
||||
confirmEndpoint: `${config.publicBaseUrl}/v1/apps/${statePayload.app_slug}/links/confirm`,
|
||||
};
|
||||
if (handoff.kind === "backend_callback") {
|
||||
await maybeDeliverBackendHandoff(handoff, {
|
||||
source: "heimdall",
|
||||
kind: "oauth_result",
|
||||
handoffKind: "backend_callback",
|
||||
attemptId: handoff.attemptId,
|
||||
connection: statePayload.connection,
|
||||
...pendingBody,
|
||||
});
|
||||
}
|
||||
if (prefersHtml(request.headers.accept)) {
|
||||
reply.type("text/html; charset=utf-8");
|
||||
return reply.send(
|
||||
renderBrowserHandoffPage({
|
||||
handoffKind: handoff.kind,
|
||||
status: "success",
|
||||
provider: request.params.provider,
|
||||
appSlug: statePayload.app_slug,
|
||||
mode: statePayload.mode,
|
||||
returnTo: statePayload.return_to,
|
||||
pendingLinkId: pendingLink.id,
|
||||
...(handoff.attemptId ? { attemptId: handoff.attemptId } : {}),
|
||||
})
|
||||
);
|
||||
}
|
||||
reply.code(201);
|
||||
return pendingBody;
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
|
|
@ -1126,32 +1314,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
await store.touchAccount(account.id, nowIso, accountUpdates);
|
||||
}
|
||||
|
||||
const linkedIdentityInput: UpsertLinkedIdentityInput = {
|
||||
accountId: account.id,
|
||||
provider: identity.provider,
|
||||
providerUserId: identity.providerUserId,
|
||||
scopes: tokenSet.scope,
|
||||
profileJson: identity.profile,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
if (identity.username !== undefined) {
|
||||
linkedIdentityInput.username = identity.username;
|
||||
}
|
||||
if (identity.displayName !== undefined) {
|
||||
linkedIdentityInput.displayName = identity.displayName;
|
||||
}
|
||||
if (identity.primaryEmail !== undefined) {
|
||||
linkedIdentityInput.primaryEmail = identity.primaryEmail;
|
||||
}
|
||||
linkedIdentityInput.accessTokenEncrypted = tokenCustody.encrypt(tokenSet.accessToken);
|
||||
if (tokenSet.refreshToken !== undefined) {
|
||||
linkedIdentityInput.refreshTokenEncrypted = tokenCustody.encrypt(tokenSet.refreshToken);
|
||||
}
|
||||
if (tokenSet.expiresAt !== undefined) {
|
||||
linkedIdentityInput.tokenExpiresAt = tokenSet.expiresAt;
|
||||
}
|
||||
await store.upsertLinkedIdentity(linkedIdentityInput);
|
||||
await store.upsertLinkedIdentity(buildLinkedIdentityInput(account.id));
|
||||
|
||||
const entitlementEvaluation = await runtime.evaluateEntitlements({
|
||||
config,
|
||||
|
|
@ -1167,13 +1330,27 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
for (const snapshot of entitlementEvaluation.snapshots) {
|
||||
await store.upsertEntitlementSnapshot(snapshot);
|
||||
}
|
||||
// The account's other linked identities count too: a member who
|
||||
// returns through a provider that grants nothing (Google) keeps what
|
||||
// Discord or Patreon grant. They are evaluated with the app's
|
||||
// start-time policy, the same call the refresh route makes.
|
||||
const linkedFacts = await evaluateStoredEntitlements({
|
||||
config,
|
||||
store,
|
||||
tokenCustody,
|
||||
oauthRuntimes,
|
||||
appSlug: statePayload.app_slug,
|
||||
accountId: account.id,
|
||||
entitlementPolicies: statePayload.entitlement_policy ? [statePayload.entitlement_policy] : [],
|
||||
skipProvider: identity.provider,
|
||||
});
|
||||
|
||||
const grants = await store.listActiveGrants(account.id, statePayload.app_slug, nowIso);
|
||||
const issueInput: IssueAccessClaimInput = {
|
||||
appSlug: statePayload.app_slug,
|
||||
accountId: account.id,
|
||||
linkedIdentities: await store.listLinkedIdentitiesForAccount(account.id),
|
||||
facts: [...entitlementEvaluation.facts, ...buildGrantFacts(grants)],
|
||||
facts: [...new Set([...entitlementEvaluation.facts, ...linkedFacts]), ...buildGrantFacts(grants)],
|
||||
...(mint === "custody_only" ? { withoutCapabilities: true } : {}),
|
||||
};
|
||||
if (account.displayName !== undefined) {
|
||||
|
|
@ -1441,6 +1618,58 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
}
|
||||
);
|
||||
|
||||
// Second phase of a link. The callback only recorded the verified identity;
|
||||
// this writes it, and only for the account whose access token began the
|
||||
// link. The bearer is the same proof start demanded, so a stranger who
|
||||
// completed the provider login (or holds the id) attaches nothing.
|
||||
app.post<{ Params: { appSlug: AppSlug }; Body: { pendingLinkId: string } }>(
|
||||
"/v1/apps/:appSlug/links/confirm",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["appSlug"],
|
||||
additionalProperties: false,
|
||||
properties: { appSlug: { type: "string", enum: [...appSlugs] } },
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["pendingLinkId"],
|
||||
additionalProperties: false,
|
||||
properties: { pendingLinkId: { type: "string", minLength: 1 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const accountId = await resolveLinkAccount({ config, keys, store }, request.params.appSlug, request.headers.authorization);
|
||||
if (!accountId) {
|
||||
reply.code(401);
|
||||
return { error: "invalid_link_session", detail: "The bearer is not a live Heimdall access token for this app." };
|
||||
}
|
||||
const nowIso = new Date().toISOString();
|
||||
const pending = await store.consumePendingLink(request.params.appSlug, request.body.pendingLinkId, accountId, nowIso);
|
||||
if (!pending) {
|
||||
reply.code(410);
|
||||
return { error: "invalid_or_expired_pending_link" };
|
||||
}
|
||||
const holder = await store.findAccountByLinkedIdentity(pending.provider, pending.providerUserId);
|
||||
if (holder && holder.id !== accountId) {
|
||||
reply.code(409);
|
||||
return { error: "identity_linked_elsewhere" };
|
||||
}
|
||||
await store.upsertLinkedIdentity({ ...pending.identity, accountId, updatedAt: nowIso });
|
||||
await store.createAuditEvent({
|
||||
accountId,
|
||||
appSlug: pending.appSlug,
|
||||
eventType: "link_confirmed",
|
||||
eventPayloadJson: { provider: pending.provider },
|
||||
createdAt: nowIso,
|
||||
});
|
||||
reply.code(201);
|
||||
return { status: "linked", provider: pending.provider, accountId, returnTo: pending.returnTo };
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { appSlug: AppSlug }; Body: RedeemAuthCompletionRequest }>(
|
||||
"/v1/apps/:appSlug/auth-completions/redeem",
|
||||
{
|
||||
|
|
|
|||
|
|
@ -39,7 +39,16 @@ export interface BackendHandoffErrorPayload extends BackendHandoffBase {
|
|||
errorDescription?: string;
|
||||
}
|
||||
|
||||
export type BackendHandoffPayload = BackendHandoffSuccessPayload | BackendHandoffErrorPayload;
|
||||
/** A verified link that waits for the initiating account's confirm: no session, no tokens. */
|
||||
export interface BackendHandoffLinkPendingPayload extends BackendHandoffBase {
|
||||
status: "link_pending";
|
||||
mode: OAuthMode;
|
||||
pendingLinkId: string;
|
||||
expiresAt: string;
|
||||
confirmEndpoint: string;
|
||||
}
|
||||
|
||||
export type BackendHandoffPayload = BackendHandoffSuccessPayload | BackendHandoffErrorPayload | BackendHandoffLinkPendingPayload;
|
||||
|
||||
export async function deliverBackendHandoff(callbackUrl: string, payload: BackendHandoffPayload): Promise<void> {
|
||||
const response = await fetch(callbackUrl, {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ export interface BrowserHandoffSuccess extends BrowserHandoffBase {
|
|||
status: "success";
|
||||
mode: OAuthMode;
|
||||
completionCode?: string;
|
||||
/** A verified link waiting for its initiator's confirm; carries no session. */
|
||||
pendingLinkId?: string;
|
||||
attemptId?: string;
|
||||
}
|
||||
|
||||
|
|
@ -47,6 +49,8 @@ function buildBrowserFallbackUrl(payload: BrowserHandoffPayload): string {
|
|||
|
||||
if (payload.status === "success" && payload.completionCode) {
|
||||
fragment.set("heimdall_completion_code", payload.completionCode);
|
||||
} else if (payload.status === "success" && payload.pendingLinkId) {
|
||||
fragment.set("heimdall_pending_link_id", payload.pendingLinkId);
|
||||
} else {
|
||||
if (payload.status === "error") {
|
||||
fragment.set("heimdall_error", payload.error);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick"] as const;
|
||||
export const providers = ["discord", "patreon", "github", "twitch", "youtube", "spotify", "joystick", "google"] as const;
|
||||
|
||||
export type Provider = (typeof providers)[number];
|
||||
|
||||
|
|
|
|||
228
src/google-oidc.ts
Normal file
228
src/google-oidc.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import { createHash, createPublicKey, sign, verify, type KeyObject } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Google sign-in as OpenID Connect: the id_token is the identity, and this
|
||||
* file is the one place that decides whether to believe it. The subject
|
||||
* (`sub`) is the account key; the email is a claim about the subject, never
|
||||
* its key.
|
||||
*/
|
||||
|
||||
export const GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"] as const;
|
||||
export const GOOGLE_JWKS_URI = "https://www.googleapis.com/oauth2/v3/certs";
|
||||
|
||||
const CLOCK_LEEWAY_SECONDS = 60;
|
||||
const DEFAULT_JWKS_TTL_SECONDS = 3600;
|
||||
const MAX_JWKS_TTL_SECONDS = 24 * 3600;
|
||||
/** An unknown `kid` refetches the key set at most this often. */
|
||||
const MIN_JWKS_REFETCH_MS = 60_000;
|
||||
|
||||
export type GoogleIdTokenFailure =
|
||||
| "malformed"
|
||||
| "unsupported_algorithm"
|
||||
| "unknown_key"
|
||||
| "bad_signature"
|
||||
| "bad_issuer"
|
||||
| "bad_audience"
|
||||
| "expired"
|
||||
| "not_yet_valid"
|
||||
| "bad_nonce"
|
||||
| "missing_subject"
|
||||
| "email_missing"
|
||||
| "email_not_verified"
|
||||
| "jwks_unavailable";
|
||||
|
||||
/** A refusal that names the rule that failed and quotes nothing from the token. */
|
||||
export class GoogleIdTokenError extends Error {
|
||||
readonly code: GoogleIdTokenFailure;
|
||||
|
||||
constructor(code: GoogleIdTokenFailure) {
|
||||
super(`Google id_token refused (${code}).`);
|
||||
this.name = "GoogleIdTokenError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface GoogleIdentityClaims {
|
||||
sub: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
picture?: string;
|
||||
hd?: string;
|
||||
}
|
||||
|
||||
interface Jwk {
|
||||
kid?: string;
|
||||
kty?: string;
|
||||
alg?: string;
|
||||
n?: string;
|
||||
e?: string;
|
||||
}
|
||||
|
||||
let cachedKeys: { keys: Map<string, KeyObject>; expiresAt: number; fetchedAt: number } | null = null;
|
||||
|
||||
export function resetGoogleJwksCache(): void {
|
||||
cachedKeys = null;
|
||||
}
|
||||
|
||||
async function fetchGoogleKeys(now: number): Promise<{ keys: Map<string, KeyObject>; expiresAt: number; fetchedAt: number }> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(GOOGLE_JWKS_URI, { headers: { Accept: "application/json" } });
|
||||
} catch {
|
||||
throw new GoogleIdTokenError("jwks_unavailable");
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new GoogleIdTokenError("jwks_unavailable");
|
||||
}
|
||||
let body: { keys?: Jwk[] };
|
||||
try {
|
||||
body = (await response.json()) as { keys?: Jwk[] };
|
||||
} catch {
|
||||
throw new GoogleIdTokenError("jwks_unavailable");
|
||||
}
|
||||
const keys = new Map<string, KeyObject>();
|
||||
for (const jwk of body.keys ?? []) {
|
||||
if (jwk.kty !== "RSA" || !jwk.kid || (jwk.alg !== undefined && jwk.alg !== "RS256")) continue;
|
||||
try {
|
||||
keys.set(jwk.kid, createPublicKey({ key: jwk as never, format: "jwk" }));
|
||||
} catch {
|
||||
// A key Node cannot read verifies nothing; the others stand.
|
||||
}
|
||||
}
|
||||
const maxAge = /max-age=(\d+)/.exec(response.headers.get("cache-control") ?? "");
|
||||
const ttl = Math.min(maxAge ? Number(maxAge[1]) : DEFAULT_JWKS_TTL_SECONDS, MAX_JWKS_TTL_SECONDS);
|
||||
return { keys, expiresAt: now + ttl * 1000, fetchedAt: now };
|
||||
}
|
||||
|
||||
async function keyForKid(kid: string, now: number): Promise<KeyObject> {
|
||||
if (cachedKeys && cachedKeys.expiresAt > now) {
|
||||
const hit = cachedKeys.keys.get(kid);
|
||||
if (hit) return hit;
|
||||
// Unknown kid in a live cache: Google may have rotated. Refetch, but not on every bad token.
|
||||
if (now - cachedKeys.fetchedAt < MIN_JWKS_REFETCH_MS) {
|
||||
throw new GoogleIdTokenError("unknown_key");
|
||||
}
|
||||
}
|
||||
cachedKeys = await fetchGoogleKeys(now);
|
||||
const key = cachedKeys.keys.get(kid);
|
||||
if (!key) throw new GoogleIdTokenError("unknown_key");
|
||||
return key;
|
||||
}
|
||||
|
||||
function decodeSegment(segment: string): Record<string, unknown> {
|
||||
try {
|
||||
const value = JSON.parse(Buffer.from(segment, "base64url").toString("utf8")) as unknown;
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// fallthrough
|
||||
}
|
||||
throw new GoogleIdTokenError("malformed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a Google id_token for this client. Order is signature first, then
|
||||
* claims, so no claim of an unauthenticated token decides anything. Refuses
|
||||
* on: algorithm other than RS256, unknown key, bad signature, `iss` not
|
||||
* Google, `aud` not this client (an array `aud` also needs `azp` to be this
|
||||
* client), `exp` passed, `iat` or `nbf` in the future, `nonce` not the one
|
||||
* this authorization request carried, no `sub`, no `email`, and an email
|
||||
* Google has not verified.
|
||||
*/
|
||||
export async function verifyGoogleIdToken(
|
||||
idToken: string,
|
||||
expected: { clientId: string; nonce: string },
|
||||
nowMs: number = Date.now()
|
||||
): Promise<GoogleIdentityClaims> {
|
||||
const parts = idToken.split(".");
|
||||
if (parts.length !== 3 || parts.some((part) => part === "")) {
|
||||
throw new GoogleIdTokenError("malformed");
|
||||
}
|
||||
const [encodedHeader, encodedPayload, encodedSignature] = parts as [string, string, string];
|
||||
const header = decodeSegment(encodedHeader);
|
||||
if (header.alg !== "RS256") {
|
||||
throw new GoogleIdTokenError("unsupported_algorithm");
|
||||
}
|
||||
if (typeof header.kid !== "string" || header.kid === "") {
|
||||
throw new GoogleIdTokenError("unknown_key");
|
||||
}
|
||||
|
||||
const key = await keyForKid(header.kid, nowMs);
|
||||
const signatureValid = verify(
|
||||
"RSA-SHA256",
|
||||
Buffer.from(`${encodedHeader}.${encodedPayload}`, "utf8"),
|
||||
key,
|
||||
Buffer.from(encodedSignature, "base64url")
|
||||
);
|
||||
if (!signatureValid) {
|
||||
throw new GoogleIdTokenError("bad_signature");
|
||||
}
|
||||
|
||||
const claims = decodeSegment(encodedPayload);
|
||||
const nowSeconds = Math.floor(nowMs / 1000);
|
||||
|
||||
if (typeof claims.iss !== "string" || !(GOOGLE_ISSUERS as readonly string[]).includes(claims.iss)) {
|
||||
throw new GoogleIdTokenError("bad_issuer");
|
||||
}
|
||||
const aud = claims.aud;
|
||||
const audienceOk =
|
||||
typeof aud === "string"
|
||||
? aud === expected.clientId
|
||||
: Array.isArray(aud) && aud.includes(expected.clientId) && claims.azp === expected.clientId;
|
||||
if (!audienceOk) {
|
||||
throw new GoogleIdTokenError("bad_audience");
|
||||
}
|
||||
if (typeof claims.exp !== "number" || claims.exp + CLOCK_LEEWAY_SECONDS <= nowSeconds) {
|
||||
throw new GoogleIdTokenError("expired");
|
||||
}
|
||||
if (typeof claims.iat !== "number" || claims.iat - CLOCK_LEEWAY_SECONDS > nowSeconds) {
|
||||
throw new GoogleIdTokenError("not_yet_valid");
|
||||
}
|
||||
if (claims.nbf !== undefined && (typeof claims.nbf !== "number" || claims.nbf - CLOCK_LEEWAY_SECONDS > nowSeconds)) {
|
||||
throw new GoogleIdTokenError("not_yet_valid");
|
||||
}
|
||||
if (typeof claims.nonce !== "string" || claims.nonce !== expected.nonce) {
|
||||
throw new GoogleIdTokenError("bad_nonce");
|
||||
}
|
||||
if (typeof claims.sub !== "string" || claims.sub === "" || claims.sub.length > 255) {
|
||||
throw new GoogleIdTokenError("missing_subject");
|
||||
}
|
||||
if (typeof claims.email !== "string" || claims.email === "") {
|
||||
throw new GoogleIdTokenError("email_missing");
|
||||
}
|
||||
if (claims.email_verified !== true && claims.email_verified !== "true") {
|
||||
throw new GoogleIdTokenError("email_not_verified");
|
||||
}
|
||||
|
||||
const identity: GoogleIdentityClaims = { sub: claims.sub, email: claims.email };
|
||||
if (typeof claims.name === "string" && claims.name !== "") identity.name = claims.name;
|
||||
if (typeof claims.picture === "string" && claims.picture !== "") identity.picture = claims.picture;
|
||||
if (typeof claims.hd === "string" && claims.hd !== "") identity.hd = claims.hd;
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* The PKCE verifier and the nonce of one authorization request, derived from
|
||||
* the request's state `jti` and Heimdall's signing key instead of stored. The
|
||||
* state token travels through the browser and carries the `jti`, so whoever
|
||||
* can read the redirect can read the `jti`; they cannot compute an Ed25519
|
||||
* signature over it. The signature is deterministic, so start and callback
|
||||
* derive the same pair, and nothing is persisted between them.
|
||||
*/
|
||||
export function deriveOidcSecrets(stateJti: string, signingKey: KeyObject): {
|
||||
codeVerifier: string;
|
||||
codeChallenge: string;
|
||||
nonce: string;
|
||||
} {
|
||||
const derive = (label: string): string =>
|
||||
createHash("sha256")
|
||||
.update(sign(null, Buffer.from(`heimdall:oidc:${label}:${stateJti}`, "utf8"), signingKey))
|
||||
.digest("base64url");
|
||||
const codeVerifier = derive("pkce-verifier");
|
||||
return {
|
||||
codeVerifier,
|
||||
codeChallenge: createHash("sha256").update(codeVerifier, "ascii").digest("base64url"),
|
||||
nonce: derive("nonce"),
|
||||
};
|
||||
}
|
||||
50
src/oauth.ts
50
src/oauth.ts
|
|
@ -7,6 +7,7 @@ import {
|
|||
} from "./contracts.js";
|
||||
import { type HeimdallConfig } from "./config.js";
|
||||
import { entitlementFacts } from "./facts.js";
|
||||
import { verifyGoogleIdToken } from "./google-oidc.js";
|
||||
import { providerCatalog } from "./providers.js";
|
||||
import { type CreateEntitlementSnapshotInput } from "./store/types.js";
|
||||
|
||||
|
|
@ -49,13 +50,22 @@ export interface OAuthProviderRuntime {
|
|||
config: HeimdallConfig;
|
||||
code: string;
|
||||
redirectUri: string;
|
||||
/** PKCE verifier of an OIDC provider's request (RFC 7636). */
|
||||
codeVerifier?: string;
|
||||
}): Promise<OAuthTokenSet>;
|
||||
refreshAccessToken?(options: {
|
||||
config: HeimdallConfig;
|
||||
refreshToken: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ExpiringTokenSet>;
|
||||
resolveIdentity(options: { config: HeimdallConfig; accessToken: string }): Promise<ResolvedIdentity>;
|
||||
resolveIdentity(options: {
|
||||
config: HeimdallConfig;
|
||||
accessToken: string;
|
||||
/** The exchange's answer: an OIDC provider's identity is its id_token, not a lookup with the access token. */
|
||||
tokenSet?: OAuthTokenSet;
|
||||
/** The nonce this authorization request carried; an OIDC provider's id_token must echo it. */
|
||||
oidcNonce?: string;
|
||||
}): Promise<ResolvedIdentity>;
|
||||
evaluateEntitlements(options: {
|
||||
config: HeimdallConfig;
|
||||
callback: OAuthCallbackContext;
|
||||
|
|
@ -144,7 +154,7 @@ function normalizeScopes(scope: string | string[] | undefined): string[] {
|
|||
}
|
||||
|
||||
export type TokenGrant =
|
||||
| { grant_type: "authorization_code"; code: string; redirect_uri: string }
|
||||
| { grant_type: "authorization_code"; code: string; redirect_uri: string; code_verifier?: string }
|
||||
| { grant_type: "refresh_token"; refresh_token: string };
|
||||
|
||||
/**
|
||||
|
|
@ -249,6 +259,7 @@ function sharedRuntimeExchange(provider: Provider): OAuthProviderRuntime["exchan
|
|||
grant_type: "authorization_code",
|
||||
code: options.code,
|
||||
redirect_uri: options.redirectUri,
|
||||
...(options.codeVerifier !== undefined ? { code_verifier: options.codeVerifier } : {}),
|
||||
});
|
||||
// A rotating provider's refresh token is the only way back to the
|
||||
// credential: a grant that arrives without one would be stored as a row
|
||||
|
|
@ -527,6 +538,31 @@ async function resolveYouTubeIdentity(options: { accessToken: string }): Promise
|
|||
return identity;
|
||||
}
|
||||
|
||||
async function resolveGoogleIdentity(options: {
|
||||
config: HeimdallConfig;
|
||||
tokenSet?: OAuthTokenSet;
|
||||
oidcNonce?: string;
|
||||
}): Promise<ResolvedIdentity> {
|
||||
const clientId = options.config.providers.google.clientId;
|
||||
const idToken = options.tokenSet?.raw.id_token;
|
||||
if (!clientId || typeof idToken !== "string" || !options.oidcNonce) {
|
||||
throw new Error("Google sign-in needs a client id, an id_token and the request's nonce.");
|
||||
}
|
||||
const claims = await verifyGoogleIdToken(idToken, { clientId, nonce: options.oidcNonce });
|
||||
const identity: ResolvedIdentity = {
|
||||
provider: "google",
|
||||
providerUserId: claims.sub,
|
||||
username: claims.email,
|
||||
displayName: claims.name ?? claims.email,
|
||||
primaryEmail: claims.email,
|
||||
profile: { sub: claims.sub, email: claims.email, email_verified: true, name: claims.name, picture: claims.picture, hd: claims.hd },
|
||||
};
|
||||
if (claims.picture) {
|
||||
identity.avatarUrl = claims.picture;
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
async function resolveSpotifyIdentity(options: { accessToken: string }): Promise<ResolvedIdentity> {
|
||||
const profile = await fetchJson<{
|
||||
id?: string;
|
||||
|
|
@ -945,6 +981,15 @@ const youtubeRuntime: OAuthProviderRuntime = {
|
|||
},
|
||||
};
|
||||
|
||||
const googleRuntime: OAuthProviderRuntime = {
|
||||
exchangeAuthorizationCode: sharedRuntimeExchange("google"),
|
||||
refreshAccessToken: sharedRuntimeRefresh("google"),
|
||||
resolveIdentity: resolveGoogleIdentity,
|
||||
async evaluateEntitlements() {
|
||||
return { facts: [], snapshots: [] };
|
||||
},
|
||||
};
|
||||
|
||||
const spotifyRuntime: OAuthProviderRuntime = {
|
||||
exchangeAuthorizationCode: sharedRuntimeExchange("spotify"),
|
||||
refreshAccessToken: sharedRuntimeRefresh("spotify"),
|
||||
|
|
@ -986,5 +1031,6 @@ export function createOAuthRuntimeRegistry(): OAuthRuntimeRegistry {
|
|||
youtube: youtubeRuntime,
|
||||
spotify: spotifyRuntime,
|
||||
joystick: joystickRuntime,
|
||||
google: googleRuntime,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,18 @@ export interface ProviderDescriptor {
|
|||
clientIdEnv: string;
|
||||
clientSecretEnv: string;
|
||||
additionalAuthorizationParams?: Record<string, string>;
|
||||
/**
|
||||
* What a callback does with a (provider, subject) no account holds yet.
|
||||
* `mints_account` is every provider's behaviour before Google: the first
|
||||
* login makes an account. `requires_link` never mints: the identity
|
||||
* attaches only to the account a verified link start bound
|
||||
* (`initiator_account_id`), otherwise the callback refuses. Google is a second
|
||||
* door into a person who already has an account, so a first Google login
|
||||
* must not make a stranger of them.
|
||||
*/
|
||||
firstLogin: "mints_account" | "requires_link";
|
||||
/** OpenID Connect: the callback verifies an id_token and uses PKCE (S256) and a nonce. */
|
||||
oidc?: true;
|
||||
}
|
||||
|
||||
export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
||||
|
|
@ -35,6 +47,7 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: ["identify", "guilds.members.read"],
|
||||
roles: ["identity", "entitlement"],
|
||||
firstLogin: "mints_account",
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET_FILE",
|
||||
},
|
||||
|
|
@ -48,6 +61,7 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: ["identity", "identity[email]"],
|
||||
roles: ["identity", "entitlement"],
|
||||
firstLogin: "mints_account",
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET_FILE",
|
||||
},
|
||||
|
|
@ -61,6 +75,7 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: ["read:user", "user:email"],
|
||||
roles: ["identity", "managed_credential"],
|
||||
firstLogin: "mints_account",
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET_FILE",
|
||||
},
|
||||
|
|
@ -74,6 +89,7 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: ["user:read:email"],
|
||||
roles: ["identity", "managed_credential"],
|
||||
firstLogin: "mints_account",
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET_FILE",
|
||||
},
|
||||
|
|
@ -92,6 +108,7 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
"https://www.googleapis.com/auth/youtube.readonly",
|
||||
],
|
||||
roles: ["identity", "managed_credential"],
|
||||
firstLogin: "mints_account",
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET_FILE",
|
||||
additionalAuthorizationParams: {
|
||||
|
|
@ -114,9 +131,26 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
"user-modify-playback-state",
|
||||
],
|
||||
roles: ["identity", "managed_credential"],
|
||||
firstLogin: "mints_account",
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE",
|
||||
},
|
||||
google: {
|
||||
key: "google",
|
||||
displayName: "Google",
|
||||
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenEndpoint: "https://oauth2.googleapis.com/token",
|
||||
tokenEndpointAuthMethod: "client_secret_post",
|
||||
rotatesRefreshTokens: false,
|
||||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: ["openid", "email", "profile"],
|
||||
roles: ["identity"],
|
||||
firstLogin: "requires_link",
|
||||
oidc: true,
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_GOOGLE_CLIENT_SECRET_FILE",
|
||||
additionalAuthorizationParams: { prompt: "select_account" },
|
||||
},
|
||||
joystick: {
|
||||
key: "joystick",
|
||||
displayName: "Joystick",
|
||||
|
|
@ -127,6 +161,7 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
|||
defaultAccessTokenLifetimeSeconds: 3600,
|
||||
defaultScopes: ["identity:read", "stream:read", "chat:read", "followers:read", "subscribers:read"],
|
||||
roles: ["identity", "managed_credential"],
|
||||
firstLogin: "mints_account",
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_JOYSTICK_CLIENT_SECRET_FILE",
|
||||
},
|
||||
|
|
@ -138,6 +173,8 @@ export function buildAuthorizationUrl(options: {
|
|||
redirectUri: string;
|
||||
state: string;
|
||||
requestedScopes: string[] | undefined;
|
||||
/** For an OIDC provider: the nonce, and the S256 challenge of the PKCE verifier. */
|
||||
oidc?: { nonce: string; codeChallenge: string };
|
||||
}): string {
|
||||
const descriptor = providerCatalog[options.provider];
|
||||
const url = new URL(descriptor.authorizationEndpoint);
|
||||
|
|
@ -148,6 +185,14 @@ export function buildAuthorizationUrl(options: {
|
|||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("scope", scopes.join(" "));
|
||||
url.searchParams.set("state", options.state);
|
||||
if (descriptor.oidc) {
|
||||
if (!options.oidc) {
|
||||
throw new Error(`${descriptor.displayName} is an OIDC provider: its authorization URL needs a nonce and a PKCE challenge.`);
|
||||
}
|
||||
url.searchParams.set("nonce", options.oidc.nonce);
|
||||
url.searchParams.set("code_challenge", options.oidc.codeChallenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(descriptor.additionalAuthorizationParams ?? {})) {
|
||||
url.searchParams.set(key, value);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import {
|
|||
type CreateAccountInput,
|
||||
type CreateAuthAttemptInput,
|
||||
type CreateAuthCompletionInput,
|
||||
type CreatePendingLinkInput,
|
||||
type StoredPendingLink,
|
||||
type CreateAuditEventInput,
|
||||
type CreateCapabilityGrantInput,
|
||||
type CredentialDecision,
|
||||
|
|
@ -47,6 +49,7 @@ export class InMemoryStore implements HeimdallStore {
|
|||
private readonly authAttempts = new Map<string, StoredAuthAttempt>();
|
||||
private readonly privateCommandReceipts = new Map<string, StoredPrivateCommandReceipt>();
|
||||
private readonly authCompletions = new Map<string, StoredAuthCompletion>();
|
||||
private readonly pendingLinks = new Map<string, StoredPendingLink>();
|
||||
private readonly authCompletionsByAttempt = new Map<string, string>();
|
||||
private readonly entitlementSnapshots = new Map<string, CreateEntitlementSnapshotInput>();
|
||||
private readonly auditEvents = new Map<string, CreateAuditEventInput>();
|
||||
|
|
@ -430,6 +433,21 @@ export class InMemoryStore implements HeimdallStore {
|
|||
return this.consumeAuthCompletion(appSlug, code, at);
|
||||
}
|
||||
|
||||
async createPendingLink(input: CreatePendingLinkInput): Promise<StoredPendingLink> {
|
||||
const link: StoredPendingLink = { id: randomUUID(), ...clone(input) };
|
||||
this.pendingLinks.set(link.id, clone(link));
|
||||
return clone(link);
|
||||
}
|
||||
|
||||
async consumePendingLink(appSlug: AppSlug, id: string, accountId: string, at: string): Promise<StoredPendingLink | null> {
|
||||
const link = this.pendingLinks.get(id);
|
||||
if (!link || link.appSlug !== appSlug || link.accountId !== accountId || link.expiresAt <= at || link.consumedAt) {
|
||||
return null;
|
||||
}
|
||||
link.consumedAt = at;
|
||||
return clone(link);
|
||||
}
|
||||
|
||||
async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void> {
|
||||
const key = `${input.accountId}:${input.provider}:${input.scope}`;
|
||||
this.entitlementSnapshots.set(key, clone(input));
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import {
|
|||
type CreateAccountInput,
|
||||
type CreateAuthAttemptInput,
|
||||
type CreateAuthCompletionInput,
|
||||
type CreatePendingLinkInput,
|
||||
type StoredPendingLink,
|
||||
type CreateAuditEventInput,
|
||||
type CreateCapabilityGrantInput,
|
||||
type CredentialDecision,
|
||||
|
|
@ -89,6 +91,19 @@ interface AuthCompletionRow extends QueryResultRow {
|
|||
consumed_at: string | Date | null;
|
||||
}
|
||||
|
||||
interface PendingLinkRow extends QueryResultRow {
|
||||
id: string;
|
||||
app_slug: AppSlug;
|
||||
account_id: string;
|
||||
provider: Provider;
|
||||
provider_user_id: string;
|
||||
identity_json: StoredPendingLink["identity"];
|
||||
return_to: string;
|
||||
created_at: string | Date;
|
||||
expires_at: string | Date;
|
||||
consumed_at: string | Date | null;
|
||||
}
|
||||
|
||||
interface AuthAttemptRow extends QueryResultRow {
|
||||
handle: string;
|
||||
app_slug: AppSlug;
|
||||
|
|
@ -232,6 +247,24 @@ function mapSessionRow(row: SessionRow): StoredSession {
|
|||
};
|
||||
}
|
||||
|
||||
function mapPendingLinkRow(row: PendingLinkRow): StoredPendingLink {
|
||||
const link: StoredPendingLink = {
|
||||
id: row.id,
|
||||
appSlug: row.app_slug,
|
||||
accountId: row.account_id,
|
||||
provider: row.provider,
|
||||
providerUserId: row.provider_user_id,
|
||||
identity: row.identity_json,
|
||||
returnTo: row.return_to,
|
||||
createdAt: normalizeTimestamp(row.created_at),
|
||||
expiresAt: normalizeTimestamp(row.expires_at),
|
||||
};
|
||||
if (row.consumed_at) {
|
||||
link.consumedAt = normalizeTimestamp(row.consumed_at);
|
||||
}
|
||||
return link;
|
||||
}
|
||||
|
||||
function mapAuthCompletionRow(row: AuthCompletionRow): StoredAuthCompletion {
|
||||
const completion: StoredAuthCompletion = {
|
||||
code: row.code,
|
||||
|
|
@ -383,6 +416,7 @@ export const STORE_TABLE_PRIVILEGES: Readonly<Record<string, readonly ("SELECT"
|
|||
auth_attempts: ["SELECT", "INSERT", "UPDATE"],
|
||||
private_command_receipts: ["SELECT", "INSERT"],
|
||||
auth_completions: ["SELECT", "INSERT", "UPDATE"],
|
||||
pending_links: ["SELECT", "INSERT", "UPDATE"],
|
||||
capability_grants: ["SELECT", "INSERT"],
|
||||
entitlement_snapshots: ["SELECT", "INSERT", "UPDATE"],
|
||||
audit_events: ["INSERT"],
|
||||
|
|
@ -1075,6 +1109,48 @@ export class PostgresStore implements HeimdallStore {
|
|||
return result.rowCount ? mapAuthCompletionRow(expectRow(result.rows[0], "consumeAuthCompletionByAttempt")) : null;
|
||||
}
|
||||
|
||||
async createPendingLink(input: CreatePendingLinkInput): Promise<StoredPendingLink> {
|
||||
const result = await this.pool.query<PendingLinkRow>(
|
||||
`
|
||||
INSERT INTO pending_links (
|
||||
id, app_slug, account_id, provider, provider_user_id, identity_json,
|
||||
return_to, created_at, expires_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
randomUUID(),
|
||||
input.appSlug,
|
||||
input.accountId,
|
||||
input.provider,
|
||||
input.providerUserId,
|
||||
JSON.stringify(input.identity),
|
||||
input.returnTo,
|
||||
input.createdAt,
|
||||
input.expiresAt,
|
||||
]
|
||||
);
|
||||
return mapPendingLinkRow(expectRow(result.rows[0], "createPendingLink"));
|
||||
}
|
||||
|
||||
async consumePendingLink(appSlug: AppSlug, id: string, accountId: string, at: string): Promise<StoredPendingLink | null> {
|
||||
const result = await this.pool.query<PendingLinkRow>(
|
||||
`
|
||||
UPDATE pending_links
|
||||
SET consumed_at = $4
|
||||
WHERE id = $1
|
||||
AND app_slug = $2
|
||||
AND account_id = $3
|
||||
AND consumed_at IS NULL
|
||||
AND expires_at > $4
|
||||
RETURNING *
|
||||
`,
|
||||
[id, appSlug, accountId, at]
|
||||
);
|
||||
return result.rowCount ? mapPendingLinkRow(expectRow(result.rows[0], "consumePendingLink")) : null;
|
||||
}
|
||||
|
||||
async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void> {
|
||||
await this.pool.query(
|
||||
`
|
||||
|
|
|
|||
|
|
@ -82,6 +82,19 @@ CREATE TABLE IF NOT EXISTS private_command_receipts (
|
|||
CREATE INDEX IF NOT EXISTS private_command_receipts_expiry_idx
|
||||
ON private_command_receipts(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pending_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
app_slug TEXT NOT NULL,
|
||||
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
provider_user_id TEXT NOT NULL,
|
||||
identity_json JSONB NOT NULL,
|
||||
return_to TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
consumed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_completions (
|
||||
code TEXT PRIMARY KEY,
|
||||
app_slug TEXT NOT NULL,
|
||||
|
|
|
|||
|
|
@ -185,6 +185,28 @@ export interface CreateAuthCompletionInput {
|
|||
payloadJson: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A link the callback has verified but not written. The identity attaches to
|
||||
* `accountId` (the account whose Heimdall access token began the link) only
|
||||
* when that same account confirms it; until then no linked-identity row
|
||||
* exists. `identity` holds the already-encrypted row to write.
|
||||
*/
|
||||
export interface StoredPendingLink {
|
||||
id: string;
|
||||
appSlug: AppSlug;
|
||||
accountId: string;
|
||||
provider: Provider;
|
||||
providerUserId: string;
|
||||
identity: UpsertLinkedIdentityInput;
|
||||
returnTo: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
consumedAt?: string;
|
||||
}
|
||||
|
||||
/** The store mints `id`; a caller cannot choose it. */
|
||||
export type CreatePendingLinkInput = Omit<StoredPendingLink, "id" | "consumedAt">;
|
||||
|
||||
export interface StoredAuthAttempt {
|
||||
handle: string;
|
||||
appSlug: AppSlug;
|
||||
|
|
@ -276,6 +298,13 @@ export interface HeimdallStore {
|
|||
* it created without ever needing to know (or forge) the code.
|
||||
*/
|
||||
consumeAuthCompletionByAttempt(appSlug: AppSlug, attemptId: string, at: string): Promise<StoredAuthCompletion | null>;
|
||||
createPendingLink(input: CreatePendingLinkInput): Promise<StoredPendingLink>;
|
||||
/**
|
||||
* Single-use redemption: returns the link only for the app and the account
|
||||
* it was begun by, before it expires and once. A mismatch returns null and
|
||||
* leaves the link unconsumed.
|
||||
*/
|
||||
consumePendingLink(appSlug: AppSlug, id: string, accountId: string, at: string): Promise<StoredPendingLink | null>;
|
||||
upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void>;
|
||||
createAuditEvent(input: CreateAuditEventInput): Promise<void>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -291,7 +291,7 @@ function testConfig(): HeimdallConfig {
|
|||
storage: { backend: "memory", applySchemaOnStartup: true },
|
||||
providers: {
|
||||
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
|
||||
patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {},
|
||||
patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {}, google: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ function createTestConfig(): HeimdallConfig {
|
|||
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
|
||||
spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" },
|
||||
joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" },
|
||||
google: { clientId: "google-client", clientSecret: "google-secret" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ function testConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConfig {
|
|||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
google: {},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
|
@ -264,8 +265,8 @@ describe("R21.1: the attempt handle is unreachable from the public start route",
|
|||
const calls = source.match(/(?<!function )startOAuthFlow\(/g) ?? [];
|
||||
if (calls.length) callers.push(`${file}:${calls.length}`);
|
||||
if (file.endsWith("app.ts")) {
|
||||
// The HTTP route's call ends at the body; no fifth argument.
|
||||
expect(source).toMatch(/startOAuthFlow\(\{ config, keys, store \}, request\.params\.provider, caller, request\.body\);/);
|
||||
// The HTTP route passes only the bearer-derived linkAccountId; never an attempt handle.
|
||||
expect(source).toMatch(/startOAuthFlow\(\{ config, keys, store \}, request\.params\.provider, caller, request\.body, \{\s*\.\.\.\(linkAccountId !== undefined \? \{ linkAccountId \} : \{\}\),\s*\}\);/);
|
||||
}
|
||||
}
|
||||
expect(callers.sort()).toEqual(["app.ts:1", "private-command-plane.ts:1"]);
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ function testConfig(): HeimdallConfig {
|
|||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
google: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ function testConfig(): HeimdallConfig {
|
|||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
google: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
502
tests/google-link.test.ts
Normal file
502
tests/google-link.test.ts
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
import { createHash, createSign, generateKeyPairSync } from "node:crypto";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { type FastifyInstance } from "fastify";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig, type HeimdallConfig } from "../src/config.js";
|
||||
import { resetGoogleJwksCache } from "../src/google-oidc.js";
|
||||
import { type OAuthProviderRuntime } from "../src/oauth.js";
|
||||
import { InMemoryStore } from "../src/store/index.js";
|
||||
|
||||
const CLIENT_ID = "google-client";
|
||||
const REDIRECT = "https://heimdall.gamecult.org/v1/oauth/google/callback";
|
||||
const rsa = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const originalFetch = globalThis.fetch;
|
||||
const apps: FastifyInstance[] = [];
|
||||
|
||||
function config(): HeimdallConfig {
|
||||
const base = loadConfig({
|
||||
GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org",
|
||||
GC_ACCESS_PROVIDER_GOOGLE_CLIENT_ID: CLIENT_ID,
|
||||
GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID: "discord-client",
|
||||
} as NodeJS.ProcessEnv);
|
||||
return {
|
||||
...base,
|
||||
storage: { backend: "memory", applySchemaOnStartup: true },
|
||||
tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
|
||||
bootstrapSigningPrivateKeyOnMissing: true,
|
||||
appSharedSecrets: { ...base.appSharedSecrets, bifrost: "bifrost-secret" },
|
||||
providers: {
|
||||
...base.providers,
|
||||
google: { clientId: CLIENT_ID, clientSecret: "google-secret" },
|
||||
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function discordRuntime(providerUserId: string, name: string): OAuthProviderRuntime {
|
||||
return {
|
||||
async exchangeAuthorizationCode() {
|
||||
return { accessToken: "d-at", refreshToken: "d-rt", tokenType: "Bearer", scope: ["identify"], expiresAt: "2099-01-01T00:00:00.000Z", raw: {} };
|
||||
},
|
||||
async resolveIdentity() {
|
||||
return { provider: "discord", providerUserId, username: name, displayName: name, profile: { id: providerUserId } };
|
||||
},
|
||||
// Discord grants app access only under a policy the app named at start.
|
||||
async evaluateEntitlements(input) {
|
||||
return { facts: input.callback.entitlementPolicy ? ["entitlement.app_access"] : [], snapshots: [] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function b64(value: unknown): string {
|
||||
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
}
|
||||
|
||||
function signedIdToken(claims: Record<string, unknown>): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const body = `${b64({ alg: "RS256", kid: "k1" })}.${b64({
|
||||
iss: "https://accounts.google.com",
|
||||
aud: CLIENT_ID,
|
||||
azp: CLIENT_ID,
|
||||
sub: "g-sub-1",
|
||||
email: "person@example.org",
|
||||
email_verified: true,
|
||||
name: "Person",
|
||||
iat: now,
|
||||
exp: now + 600,
|
||||
...claims,
|
||||
})}`;
|
||||
return `${body}.${createSign("RSA-SHA256").update(body).sign(rsa.privateKey).toString("base64url")}`;
|
||||
}
|
||||
|
||||
interface GoogleLeg {
|
||||
tokenRequests: URLSearchParams[];
|
||||
}
|
||||
|
||||
/** Google's two endpoints: the key set, and a token endpoint whose id_token echoes the nonce it was told. */
|
||||
function fakeGoogle(options: { nonceFrom: () => string | undefined; claims?: Record<string, unknown> }): GoogleLeg {
|
||||
const leg: GoogleLeg = { tokenRequests: [] };
|
||||
globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url === "https://www.googleapis.com/oauth2/v3/certs") {
|
||||
return new Response(
|
||||
JSON.stringify({ keys: [{ ...(rsa.publicKey.export({ format: "jwk" }) as object), kid: "k1", alg: "RS256" }] }),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (url === "https://oauth2.googleapis.com/token") {
|
||||
leg.tokenRequests.push(new URLSearchParams(String(init?.body)));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "g-at",
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
scope: "openid email profile",
|
||||
id_token: signedIdToken({ nonce: options.nonceFrom(), ...options.claims }),
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
}) as typeof fetch;
|
||||
return leg;
|
||||
}
|
||||
|
||||
function accountCount(store: InMemoryStore): number {
|
||||
return (store as unknown as { accounts: Map<string, unknown> }).accounts.size;
|
||||
}
|
||||
|
||||
async function signInWithDiscord(app: FastifyInstance, providerUserId = "discord-1"): Promise<{ accessToken: string; accountId: string }> {
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/discord/start",
|
||||
payload: { appSlug: "bifrost", mode: "sign_in", returnTo: "https://bifrost.gamecult.org/auth/heimdall/complete" },
|
||||
});
|
||||
expect(start.statusCode).toBe(201);
|
||||
const callback = await app.inject({
|
||||
method: "GET",
|
||||
url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(start.json().stateToken)}`,
|
||||
});
|
||||
expect(callback.statusCode).toBe(201);
|
||||
const body = callback.json();
|
||||
return { accessToken: body.accessToken as string, accountId: body.account.id as string };
|
||||
}
|
||||
|
||||
async function build(store = new InMemoryStore(), discordUser = "discord-1") {
|
||||
const app = await buildApp({ config: config(), store, oauthRuntimes: { discord: discordRuntime(discordUser, "Person") } });
|
||||
apps.push(app);
|
||||
return { app, store };
|
||||
}
|
||||
|
||||
function startGoogle(app: FastifyInstance, accessToken?: string, mode: "link" | "sign_in" = "link") {
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/google/start",
|
||||
headers: accessToken ? { authorization: `Bearer ${accessToken}` } : {},
|
||||
payload: { appSlug: "bifrost", mode, returnTo: "https://bifrost.gamecult.org/auth/heimdall/complete" },
|
||||
});
|
||||
}
|
||||
|
||||
function callbackGoogle(app: FastifyInstance, stateToken: string) {
|
||||
return app.inject({ method: "GET", url: `/v1/oauth/google/callback?code=g-code&state=${encodeURIComponent(stateToken)}` });
|
||||
}
|
||||
|
||||
const DISCORD_POLICY = { kind: "discord_role_access", guildId: "g", allowedRoleIds: ["member"] } as const;
|
||||
|
||||
/** Begin a link with `token`, let Google answer as `claims` says, and return the callback's answer. */
|
||||
async function linkGoogleCallback(app: FastifyInstance, token: string, claims?: Record<string, unknown>) {
|
||||
const start = await startGoogle(app, token);
|
||||
expect(start.statusCode).toBe(201);
|
||||
const url = new URL(start.json().authorizationUrl);
|
||||
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined, ...(claims ? { claims } : {}) });
|
||||
return callbackGoogle(app, start.json().stateToken);
|
||||
}
|
||||
|
||||
function confirmLink(app: FastifyInstance, token: string | undefined, pendingLinkId: string) {
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/apps/bifrost/links/confirm",
|
||||
headers: token ? { authorization: `Bearer ${token}` } : {},
|
||||
payload: { pendingLinkId },
|
||||
});
|
||||
}
|
||||
|
||||
/** The whole link: callback, then the initiator's confirm. */
|
||||
async function linkGoogle(app: FastifyInstance, token: string, claims?: Record<string, unknown>) {
|
||||
const done = await linkGoogleCallback(app, token, claims);
|
||||
expect(done.statusCode).toBe(201);
|
||||
const confirmed = await confirmLink(app, token, done.json().pendingLinkId);
|
||||
expect(confirmed.statusCode).toBe(201);
|
||||
return confirmed;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
resetGoogleJwksCache();
|
||||
while (apps.length) await apps.pop()?.close();
|
||||
});
|
||||
|
||||
describe("Google as a Heimdall OIDC provider", () => {
|
||||
it("builds the authorization request: openid email profile, the exact callback, state, nonce and an S256 challenge", async () => {
|
||||
const { app } = await build();
|
||||
const { accessToken } = await signInWithDiscord(app);
|
||||
const start = await startGoogle(app, accessToken);
|
||||
|
||||
expect(start.statusCode).toBe(201);
|
||||
expect(start.json().callbackUrl).toBe(REDIRECT);
|
||||
const url = new URL(start.json().authorizationUrl);
|
||||
expect(`${url.origin}${url.pathname}`).toBe("https://accounts.google.com/o/oauth2/v2/auth");
|
||||
expect(url.searchParams.get("client_id")).toBe(CLIENT_ID);
|
||||
expect(url.searchParams.get("redirect_uri")).toBe(REDIRECT);
|
||||
expect(url.searchParams.get("response_type")).toBe("code");
|
||||
expect(url.searchParams.get("scope")).toBe("openid email profile");
|
||||
expect(url.searchParams.get("state")).toBe(start.json().stateToken);
|
||||
expect(url.searchParams.get("nonce")).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
});
|
||||
|
||||
it("links Google to the account that asked, with PKCE proven end to end and the id_token's nonce matched", async () => {
|
||||
const { app, store } = await build();
|
||||
const { accessToken, accountId } = await signInWithDiscord(app);
|
||||
const start = await startGoogle(app, accessToken);
|
||||
const url = new URL(start.json().authorizationUrl);
|
||||
const leg = fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined });
|
||||
|
||||
const done = await callbackGoogle(app, start.json().stateToken);
|
||||
|
||||
// Phase one verified the identity and recorded it; it linked nothing and issued no session.
|
||||
expect(done.statusCode).toBe(201);
|
||||
expect(done.json().status).toBe("link_pending");
|
||||
expect(done.json().accessToken).toBeUndefined();
|
||||
expect(await store.findStoredLinkedIdentity("google", "g-sub-1")).toBeNull();
|
||||
const confirmed = await confirmLink(app, accessToken, done.json().pendingLinkId);
|
||||
expect(confirmed.statusCode).toBe(201);
|
||||
expect(confirmed.json().accountId).toBe(accountId);
|
||||
expect(accountCount(store)).toBe(1);
|
||||
const sent = leg.tokenRequests[0]!;
|
||||
expect(sent.get("grant_type")).toBe("authorization_code");
|
||||
expect(sent.get("redirect_uri")).toBe(REDIRECT);
|
||||
// The verifier Heimdall sent hashes to the challenge it put in the authorization URL.
|
||||
expect(createHash("sha256").update(sent.get("code_verifier") ?? "", "ascii").digest("base64url")).toBe(
|
||||
url.searchParams.get("code_challenge")
|
||||
);
|
||||
const stored = await store.findStoredLinkedIdentity("google", "g-sub-1");
|
||||
expect(stored?.accountId).toBe(accountId);
|
||||
expect(stored?.primaryEmail).toBe("person@example.org");
|
||||
});
|
||||
|
||||
it("signs in the account Google is linked to, with no session, and mints nothing", async () => {
|
||||
const { app, store } = await build();
|
||||
const { accessToken, accountId } = await signInWithDiscord(app);
|
||||
await linkGoogle(app, accessToken);
|
||||
|
||||
const signIn = await startGoogle(app, undefined, "sign_in");
|
||||
expect(signIn.statusCode).toBe(201);
|
||||
const signInUrl = new URL(signIn.json().authorizationUrl);
|
||||
fakeGoogle({ nonceFrom: () => signInUrl.searchParams.get("nonce") ?? undefined });
|
||||
const done = await callbackGoogle(app, signIn.json().stateToken);
|
||||
|
||||
expect(done.statusCode).toBe(201);
|
||||
expect(done.json().account.id).toBe(accountId);
|
||||
expect(accountCount(store)).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses a link start whose named initiator is not the bearer's account", async () => {
|
||||
const { app, store } = await build();
|
||||
const a = await signInWithDiscord(app, "discord-1");
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/google/start",
|
||||
headers: { authorization: `Bearer ${a.accessToken}` },
|
||||
payload: {
|
||||
appSlug: "bifrost",
|
||||
mode: "link",
|
||||
returnTo: "https://bifrost.gamecult.org/auth/heimdall/complete",
|
||||
initiatingAccountId: `${a.accountId}-other`,
|
||||
},
|
||||
});
|
||||
expect([start.statusCode, start.json().error]).toEqual([400, "link_initiator_conflict"]);
|
||||
expect(accountCount(store)).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses a link start with no Heimdall session", async () => {
|
||||
const { app, store } = await build();
|
||||
const start = await startGoogle(app);
|
||||
expect(start.statusCode).toBe(401);
|
||||
expect(start.json().error).toBe("link_session_required");
|
||||
expect(accountCount(store)).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a garbage bearer", () => "not-a-token"],
|
||||
["a token for another app", async (app: FastifyInstance) => {
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/discord/start",
|
||||
payload: { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" },
|
||||
});
|
||||
const cb = await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(start.json().stateToken)}` });
|
||||
return cb.json().accessToken as string;
|
||||
}],
|
||||
])("refuses a link start carrying %s", async (_name, token) => {
|
||||
const { app, store } = await build();
|
||||
const bearer = await token(app);
|
||||
const before = accountCount(store);
|
||||
const start = await startGoogle(app, bearer);
|
||||
expect(start.statusCode).toBe(401);
|
||||
expect(start.json().error).toBe("invalid_link_session");
|
||||
expect(accountCount(store)).toBe(before);
|
||||
});
|
||||
|
||||
it("refuses a first Google login from a subject no account holds, and mints no account for it", async () => {
|
||||
// The person who has a Discord account arrives with Google for the first time, signing in rather than linking.
|
||||
const { app, store } = await build();
|
||||
await signInWithDiscord(app);
|
||||
const start = await startGoogle(app, undefined, "sign_in");
|
||||
expect(start.statusCode).toBe(201);
|
||||
const url = new URL(start.json().authorizationUrl);
|
||||
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined });
|
||||
|
||||
const before = accountCount(store);
|
||||
const done = await callbackGoogle(app, start.json().stateToken);
|
||||
|
||||
expect(done.statusCode).toBe(403);
|
||||
expect(done.json().error).toBe("identity_not_linked");
|
||||
expect(accountCount(store)).toBe(before);
|
||||
expect(await store.findStoredLinkedIdentity("google", "g-sub-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("never moves or merges: a Google subject already linked to one account cannot be linked to another", async () => {
|
||||
const store = new InMemoryStore();
|
||||
const first = await build(store, "discord-1");
|
||||
const a = await signInWithDiscord(first.app, "discord-1");
|
||||
await linkGoogle(first.app, a.accessToken);
|
||||
|
||||
// A second person, a second account, the same Google subject.
|
||||
const second = await build(store, "discord-2");
|
||||
const b = await signInWithDiscord(second.app, "discord-2");
|
||||
expect(b.accountId).not.toBe(a.accountId);
|
||||
const startB = await startGoogle(second.app, b.accessToken);
|
||||
const urlB = new URL(startB.json().authorizationUrl);
|
||||
fakeGoogle({ nonceFrom: () => urlB.searchParams.get("nonce") ?? undefined });
|
||||
const done = await callbackGoogle(second.app, startB.json().stateToken);
|
||||
|
||||
expect(done.statusCode).toBe(403);
|
||||
expect((await store.findStoredLinkedIdentity("google", "g-sub-1"))?.accountId).toBe(a.accountId);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an unverified email", { email_verified: false }],
|
||||
["another client's audience", { aud: "other-client", azp: "other-client" }],
|
||||
["a nonce this request did not carry", { nonce: "replayed" }],
|
||||
["an expired token", { exp: Math.floor(Date.now() / 1000) - 3600 }],
|
||||
])("links nothing when the id_token has %s", async (_name, claims) => {
|
||||
const { app, store } = await build();
|
||||
const { accessToken } = await signInWithDiscord(app);
|
||||
const start = await startGoogle(app, accessToken);
|
||||
const url = new URL(start.json().authorizationUrl);
|
||||
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined, claims });
|
||||
|
||||
const done = await callbackGoogle(app, start.json().stateToken);
|
||||
|
||||
expect(done.statusCode).toBe(502);
|
||||
expect(await store.findStoredLinkedIdentity("google", "g-sub-1")).toBeNull();
|
||||
expect(accountCount(store)).toBe(1);
|
||||
});
|
||||
|
||||
it("keys the identity by sub: the same email under a different sub is a different, unlinkable identity", async () => {
|
||||
const { app, store } = await build();
|
||||
const { accessToken, accountId } = await signInWithDiscord(app);
|
||||
await linkGoogle(app, accessToken, { sub: "g-sub-2" });
|
||||
expect((await store.findStoredLinkedIdentity("google", "g-sub-2"))?.accountId).toBe(accountId);
|
||||
expect(await store.findStoredLinkedIdentity("google", "person@example.org")).toBeNull();
|
||||
});
|
||||
|
||||
describe("link is two-phase", () => {
|
||||
it("a victim completing an attacker's link URL attaches the victim's Google sub to no account", async () => {
|
||||
const store = new InMemoryStore();
|
||||
const attacker = await build(store, "discord-attacker");
|
||||
const a = await signInWithDiscord(attacker.app, "discord-attacker");
|
||||
const victim = await build(store, "discord-victim");
|
||||
const v = await signInWithDiscord(victim.app, "discord-victim");
|
||||
|
||||
// The attacker begins a link and sends the Google URL to the victim, who finishes it as themselves.
|
||||
const done = await linkGoogleCallback(attacker.app, a.accessToken, { sub: "victim-google-sub" });
|
||||
expect(done.statusCode).toBe(201);
|
||||
expect(await store.findStoredLinkedIdentity("google", "victim-google-sub")).toBeNull();
|
||||
|
||||
// The victim's app holds the victim's token: Heimdall refuses it, and the link stays unwritten.
|
||||
const asVictim = await confirmLink(victim.app, v.accessToken, done.json().pendingLinkId);
|
||||
expect(asVictim.statusCode).toBe(410);
|
||||
expect(await store.findStoredLinkedIdentity("google", "victim-google-sub")).toBeNull();
|
||||
|
||||
// No token at all, and a garbage token, are refused the same way.
|
||||
expect((await confirmLink(attacker.app, undefined, done.json().pendingLinkId)).statusCode).toBe(401);
|
||||
expect((await confirmLink(attacker.app, "garbage", done.json().pendingLinkId)).statusCode).toBe(401);
|
||||
expect(await store.findStoredLinkedIdentity("google", "victim-google-sub")).toBeNull();
|
||||
|
||||
// The refused attempts did not burn the pending link: its initiator can still confirm it.
|
||||
expect((await confirmLink(attacker.app, a.accessToken, done.json().pendingLinkId)).statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("signing in with Google cannot reach a Google sub the attacker's pending link recorded", async () => {
|
||||
const { app } = await build();
|
||||
const a = await signInWithDiscord(app);
|
||||
await linkGoogleCallback(app, a.accessToken, { sub: "victim-google-sub" });
|
||||
|
||||
const signIn = await startGoogle(app, undefined, "sign_in");
|
||||
const url = new URL(signIn.json().authorizationUrl);
|
||||
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined, claims: { sub: "victim-google-sub" } });
|
||||
expect((await callbackGoogle(app, signIn.json().stateToken)).statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("a pending link is single-use", async () => {
|
||||
const { app } = await build();
|
||||
const a = await signInWithDiscord(app);
|
||||
const done = await linkGoogleCallback(app, a.accessToken);
|
||||
expect((await confirmLink(app, a.accessToken, done.json().pendingLinkId)).statusCode).toBe(201);
|
||||
expect((await confirmLink(app, a.accessToken, done.json().pendingLinkId)).statusCode).toBe(410);
|
||||
});
|
||||
|
||||
it("a pending link expires after the completion TTL", async () => {
|
||||
const { app, store } = await build();
|
||||
const a = await signInWithDiscord(app);
|
||||
const done = await linkGoogleCallback(app, a.accessToken);
|
||||
const ttlMs = Date.parse(done.json().expiresAt) - Date.now();
|
||||
expect(ttlMs).toBeGreaterThan(0);
|
||||
expect(ttlMs).toBeLessThanOrEqual(300_000);
|
||||
|
||||
vi.useFakeTimers({ toFake: ["Date"], now: Date.now() + 301_000 });
|
||||
try {
|
||||
expect((await confirmLink(app, a.accessToken, done.json().pendingLinkId)).statusCode).toBe(410);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
expect(await store.findStoredLinkedIdentity("google", "g-sub-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("a pending link is bound to its app: another app's session cannot confirm it", async () => {
|
||||
const { app, store } = await build();
|
||||
const a = await signInWithDiscord(app);
|
||||
const done = await linkGoogleCallback(app, a.accessToken);
|
||||
const other = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/discord/start",
|
||||
payload: { appSlug: "repixelizer", mode: "sign_in", returnTo: "https://repixelizer.gamecult.org/app/" },
|
||||
});
|
||||
const cb = await app.inject({ method: "GET", url: `/v1/oauth/discord/callback?code=c&state=${encodeURIComponent(other.json().stateToken)}` });
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/apps/repixelizer/links/confirm",
|
||||
headers: { authorization: `Bearer ${cb.json().accessToken}` },
|
||||
payload: { pendingLinkId: done.json().pendingLinkId },
|
||||
});
|
||||
expect(res.statusCode).toBe(410);
|
||||
expect(await store.findStoredLinkedIdentity("google", "g-sub-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses at confirm a sub another account linked in between, and never moves it", async () => {
|
||||
const store = new InMemoryStore();
|
||||
const first = await build(store, "discord-1");
|
||||
const a = await signInWithDiscord(first.app, "discord-1");
|
||||
const second = await build(store, "discord-2");
|
||||
const b = await signInWithDiscord(second.app, "discord-2");
|
||||
|
||||
const pendingA = await linkGoogleCallback(first.app, a.accessToken);
|
||||
const pendingB = await linkGoogleCallback(second.app, b.accessToken);
|
||||
expect((await confirmLink(first.app, a.accessToken, pendingA.json().pendingLinkId)).statusCode).toBe(201);
|
||||
const lost = await confirmLink(second.app, b.accessToken, pendingB.json().pendingLinkId);
|
||||
|
||||
expect(lost.statusCode).toBe(409);
|
||||
expect((await store.findStoredLinkedIdentity("google", "g-sub-1"))?.accountId).toBe(a.accountId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("entitlements at the callback", () => {
|
||||
it("a Discord-origin member returning through Google keeps entitlement.app_access under the start-time policy", async () => {
|
||||
const { app } = await build();
|
||||
const member = await signInWithDiscord(app);
|
||||
await linkGoogle(app, member.accessToken);
|
||||
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/google/start",
|
||||
headers: { "x-heimdall-app-secret": "bifrost-secret" },
|
||||
payload: {
|
||||
appSlug: "bifrost",
|
||||
mode: "sign_in",
|
||||
returnTo: "https://bifrost.gamecult.org/auth/heimdall/complete",
|
||||
entitlementPolicy: DISCORD_POLICY,
|
||||
},
|
||||
});
|
||||
expect(start.statusCode).toBe(201);
|
||||
const url = new URL(start.json().authorizationUrl);
|
||||
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined });
|
||||
const done = await callbackGoogle(app, start.json().stateToken);
|
||||
|
||||
expect(done.statusCode).toBe(201);
|
||||
expect(done.json().claimSet.facts).toContain("entitlement.app_access");
|
||||
});
|
||||
|
||||
it("without a start-time policy the other identities grant nothing", async () => {
|
||||
const { app } = await build();
|
||||
const member = await signInWithDiscord(app);
|
||||
await linkGoogle(app, member.accessToken);
|
||||
|
||||
const start = await startGoogle(app, undefined, "sign_in");
|
||||
const url = new URL(start.json().authorizationUrl);
|
||||
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined });
|
||||
const done = await callbackGoogle(app, start.json().stateToken);
|
||||
|
||||
expect(done.statusCode).toBe(201);
|
||||
expect(done.json().claimSet.facts).not.toContain("entitlement.app_access");
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the six existing providers minting on first login", async () => {
|
||||
const { app, store } = await build();
|
||||
await signInWithDiscord(app);
|
||||
expect(accountCount(store)).toBe(1);
|
||||
});
|
||||
});
|
||||
197
tests/google-oidc.test.ts
Normal file
197
tests/google-oidc.test.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { createSign, generateKeyPairSync, type KeyObject } from "node:crypto";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
deriveOidcSecrets,
|
||||
GoogleIdTokenError,
|
||||
resetGoogleJwksCache,
|
||||
verifyGoogleIdToken,
|
||||
type GoogleIdTokenFailure,
|
||||
} from "../src/google-oidc.js";
|
||||
|
||||
const CLIENT_ID = "google-client.apps.googleusercontent.com";
|
||||
const NONCE = "the-nonce";
|
||||
const NOW_MS = Date.parse("2026-11-04T12:00:00.000Z");
|
||||
const NOW_S = Math.floor(NOW_MS / 1000);
|
||||
|
||||
const rsa = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const otherRsa = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const originalFetch = globalThis.fetch;
|
||||
let jwksFetches = 0;
|
||||
|
||||
function jwks(kidKeys: Record<string, KeyObject>) {
|
||||
return {
|
||||
keys: Object.entries(kidKeys).map(([kid, key]) => ({
|
||||
...(key.export({ format: "jwk" }) as Record<string, string>),
|
||||
kid,
|
||||
alg: "RS256",
|
||||
use: "sig",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function serveJwks(kidKeys: Record<string, KeyObject>) {
|
||||
globalThis.fetch = (async (input: string | URL) => {
|
||||
expect(String(input)).toBe("https://www.googleapis.com/oauth2/v3/certs");
|
||||
jwksFetches += 1;
|
||||
return new Response(JSON.stringify(jwks(kidKeys)), { status: 200, headers: { "cache-control": "public, max-age=3600" } });
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
function b64(value: unknown): string {
|
||||
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
}
|
||||
|
||||
function idToken(
|
||||
claims: Record<string, unknown> = {},
|
||||
options: { kid?: string; alg?: string; key?: KeyObject; omit?: string[] } = {}
|
||||
): string {
|
||||
const payload: Record<string, unknown> = {
|
||||
iss: "https://accounts.google.com",
|
||||
aud: CLIENT_ID,
|
||||
azp: CLIENT_ID,
|
||||
sub: "110169484474386276334",
|
||||
email: "meta@example.org",
|
||||
email_verified: true,
|
||||
name: "Meta",
|
||||
picture: "https://lh3.googleusercontent.com/a/x",
|
||||
nonce: NONCE,
|
||||
iat: NOW_S - 10,
|
||||
exp: NOW_S + 3000,
|
||||
...claims,
|
||||
};
|
||||
for (const key of options.omit ?? []) delete payload[key];
|
||||
const header = b64({ alg: options.alg ?? "RS256", typ: "JWT", kid: options.kid ?? "k1" });
|
||||
const body = `${header}.${b64(payload)}`;
|
||||
const signature = createSign("RSA-SHA256").update(body).sign(options.key ?? rsa.privateKey).toString("base64url");
|
||||
return `${body}.${signature}`;
|
||||
}
|
||||
|
||||
async function refusal(token: string, nonce = NONCE): Promise<GoogleIdTokenFailure> {
|
||||
try {
|
||||
await verifyGoogleIdToken(token, { clientId: CLIENT_ID, nonce }, NOW_MS);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(GoogleIdTokenError);
|
||||
return (error as GoogleIdTokenError).code;
|
||||
}
|
||||
throw new Error("the token was accepted");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetGoogleJwksCache();
|
||||
jwksFetches = 0;
|
||||
serveJwks({ k1: rsa.publicKey });
|
||||
});
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("verifyGoogleIdToken", () => {
|
||||
it("accepts a good token and returns the subject as the identity", async () => {
|
||||
const claims = await verifyGoogleIdToken(idToken(), { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS);
|
||||
expect(claims).toEqual({
|
||||
sub: "110169484474386276334",
|
||||
email: "meta@example.org",
|
||||
name: "Meta",
|
||||
picture: "https://lh3.googleusercontent.com/a/x",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts the bare-host issuer Google also uses", async () => {
|
||||
await expect(verifyGoogleIdToken(idToken({ iss: "accounts.google.com" }), { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS)).resolves.toMatchObject({
|
||||
sub: "110169484474386276334",
|
||||
});
|
||||
});
|
||||
|
||||
it.each<[string, () => string, GoogleIdTokenFailure]>([
|
||||
["a foreign issuer", () => idToken({ iss: "https://evil.example" }), "bad_issuer"],
|
||||
["an issuer that merely contains Google's", () => idToken({ iss: "https://accounts.google.com.evil.example" }), "bad_issuer"],
|
||||
["another client's audience", () => idToken({ aud: "other-client", azp: "other-client" }), "bad_audience"],
|
||||
["an array audience whose azp is not this client", () => idToken({ aud: [CLIENT_ID, "other"], azp: "other" }), "bad_audience"],
|
||||
["an array audience with no azp", () => idToken({ aud: [CLIENT_ID], azp: undefined }), "bad_audience"],
|
||||
["an expired token", () => idToken({ exp: NOW_S - 61 }), "expired"],
|
||||
["a token with no exp", () => idToken({}, { omit: ["exp"] }), "expired"],
|
||||
["a token issued in the future", () => idToken({ iat: NOW_S + 3600 }), "not_yet_valid"],
|
||||
["a token with no iat", () => idToken({}, { omit: ["iat"] }), "not_yet_valid"],
|
||||
["a nonce this request did not carry", () => idToken({ nonce: "someone-elses" }), "bad_nonce"],
|
||||
["a token with no nonce", () => idToken({}, { omit: ["nonce"] }), "bad_nonce"],
|
||||
["no subject", () => idToken({}, { omit: ["sub"] }), "missing_subject"],
|
||||
["an empty subject", () => idToken({ sub: "" }), "missing_subject"],
|
||||
["no email", () => idToken({}, { omit: ["email"] }), "email_missing"],
|
||||
["an unverified email", () => idToken({ email_verified: false }), "email_not_verified"],
|
||||
["no email_verified claim", () => idToken({}, { omit: ["email_verified"] }), "email_not_verified"],
|
||||
["a string email_verified other than true", () => idToken({ email_verified: "yes" }), "email_not_verified"],
|
||||
["alg none", () => idToken({}, { alg: "none" }), "unsupported_algorithm"],
|
||||
["alg HS256", () => idToken({}, { alg: "HS256" }), "unsupported_algorithm"],
|
||||
["a key Google does not publish", () => idToken({}, { kid: "unknown" }), "unknown_key"],
|
||||
["a signature by another key under Google's kid", () => idToken({}, { key: otherRsa.privateKey }), "bad_signature"],
|
||||
["three empty parts", () => "..", "malformed"],
|
||||
["two parts", () => "a.b", "malformed"],
|
||||
])("refuses %s", async (_name, make, code) => {
|
||||
expect(await refusal(make())).toBe(code);
|
||||
});
|
||||
|
||||
it("checks the signature before it believes any claim", async () => {
|
||||
// A forged payload with every claim right is still refused as a bad signature.
|
||||
const forged = idToken({}, { key: otherRsa.privateKey });
|
||||
expect(await refusal(forged)).toBe("bad_signature");
|
||||
});
|
||||
|
||||
it("takes email_verified string 'true' as Google's older tokens spell it", async () => {
|
||||
await expect(verifyGoogleIdToken(idToken({ email_verified: "true" }), { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS)).resolves.toMatchObject({
|
||||
email: "meta@example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("caches Google's keys and refetches once for a rotated kid, not for every bad token", async () => {
|
||||
await verifyGoogleIdToken(idToken(), { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS);
|
||||
await verifyGoogleIdToken(idToken(), { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS + 1000);
|
||||
expect(jwksFetches).toBe(1);
|
||||
serveJwks({ k1: rsa.publicKey, k2: otherRsa.publicKey });
|
||||
const rotated = idToken({}, { kid: "k2", key: otherRsa.privateKey });
|
||||
// Inside the refetch floor the unknown kid is refused without a fetch.
|
||||
await expect(verifyGoogleIdToken(rotated, { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS + 2000)).rejects.toMatchObject({ code: "unknown_key" });
|
||||
expect(jwksFetches).toBe(1);
|
||||
await expect(verifyGoogleIdToken(rotated, { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS + 120_000)).resolves.toMatchObject({ sub: "110169484474386276334" });
|
||||
expect(jwksFetches).toBe(2);
|
||||
});
|
||||
|
||||
it("refuses with jwks_unavailable when Google's key endpoint fails", async () => {
|
||||
resetGoogleJwksCache();
|
||||
globalThis.fetch = (async () => new Response("down", { status: 503 })) as typeof fetch;
|
||||
expect(await refusal(idToken())).toBe("jwks_unavailable");
|
||||
});
|
||||
|
||||
it("never quotes the token in its refusal", async () => {
|
||||
const token = idToken({ email: "secret-person@example.org", nonce: "wrong" });
|
||||
try {
|
||||
await verifyGoogleIdToken(token, { clientId: CLIENT_ID, nonce: NONCE }, NOW_MS);
|
||||
} catch (error) {
|
||||
expect(String((error as Error).message)).not.toContain("secret-person");
|
||||
expect(String((error as Error).message)).not.toContain(token.slice(0, 20));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveOidcSecrets", () => {
|
||||
const key = generateKeyPairSync("ed25519").privateKey;
|
||||
const otherKey = generateKeyPairSync("ed25519").privateKey;
|
||||
|
||||
it("is the same pair for the same state and Heimdall key, so nothing is stored between start and callback", () => {
|
||||
expect(deriveOidcSecrets("jti-1", key)).toEqual(deriveOidcSecrets("jti-1", key));
|
||||
});
|
||||
|
||||
it("differs per state, per Heimdall key, and between verifier and nonce", () => {
|
||||
const a = deriveOidcSecrets("jti-1", key);
|
||||
expect(deriveOidcSecrets("jti-2", key).codeVerifier).not.toBe(a.codeVerifier);
|
||||
expect(deriveOidcSecrets("jti-2", key).nonce).not.toBe(a.nonce);
|
||||
expect(deriveOidcSecrets("jti-1", otherKey).codeVerifier).not.toBe(a.codeVerifier);
|
||||
expect(a.codeVerifier).not.toBe(a.nonce);
|
||||
});
|
||||
|
||||
it("yields an RFC 7636 verifier (43-128 unreserved characters) and its S256 challenge", () => {
|
||||
const { codeVerifier, codeChallenge } = deriveOidcSecrets("jti-1", key);
|
||||
expect(codeVerifier).toMatch(/^[A-Za-z0-9_-]{43,128}$/);
|
||||
expect(codeChallenge).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(codeChallenge).not.toBe(codeVerifier);
|
||||
});
|
||||
});
|
||||
|
|
@ -49,6 +49,7 @@ function createTestConfig(): HeimdallConfig {
|
|||
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
|
||||
spotify: { clientId: "spotify-client", clientSecret: "spotify-secret" },
|
||||
joystick: { clientId: "joystick-client", clientSecret: "joystick-secret" },
|
||||
google: { clientId: "google-client", clientSecret: "google-secret" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ function config(): HeimdallConfig {
|
|||
bootstrapSigningPrivateKeyOnMissing: false, tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
|
||||
appSharedSecrets: { ghostlight: secret }, appRuntimeIds: { ghostlight: ["yggdrasil-ghostlight"] }, appBackendCallbacks: {},
|
||||
storage: { backend: "memory", applySchemaOnStartup: true },
|
||||
providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {} },
|
||||
providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {}, joystick: {}, google: {} },
|
||||
} as HeimdallConfig;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ function createTestConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConf
|
|||
youtube: {},
|
||||
spotify: {},
|
||||
joystick: {},
|
||||
google: {},
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -232,12 +232,13 @@ describe("PostgresStore.checkSchema", () => {
|
|||
"sessions",
|
||||
"auth_attempts",
|
||||
"private_command_receipts",
|
||||
"pending_links",
|
||||
"auth_completions",
|
||||
"capability_grants",
|
||||
"entitlement_snapshots",
|
||||
"audit_events",
|
||||
]);
|
||||
expect(REQUIRED_COLUMNS).toHaveLength(86);
|
||||
expect(REQUIRED_COLUMNS).toHaveLength(96);
|
||||
expect(REQUIRED_COLUMNS).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ table: "auth_completions", column: "code", type: "TEXT", nullable: false },
|
||||
|
|
@ -248,7 +249,7 @@ describe("PostgresStore.checkSchema", () => {
|
|||
{ table: "auth_completions", column: "attempt_id", type: "TEXT", nullable: true },
|
||||
])
|
||||
);
|
||||
expect(REQUIRED_KEYS).toHaveLength(12);
|
||||
expect(REQUIRED_KEYS).toHaveLength(13);
|
||||
expect(REQUIRED_KEYS).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ table: "linked_identities", columns: "provider,provider_user_id", predicate: "" },
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ describe("Heimdall verse runtime publication", () => {
|
|||
expect(bySchema.get("idunn.daemon_health")).toEqual([
|
||||
"yggdrasil-heimdall",
|
||||
"active",
|
||||
"Heimdall auth runtime active; storage=postgres; providers=2/7; apps=4; healthTransport=CultNet/RUDP",
|
||||
"Heimdall auth runtime active; storage=postgres; providers=2/8; apps=4; healthTransport=CultNet/RUDP",
|
||||
"2026-06-16T17:10:00.000Z",
|
||||
"heimdall.cultnet-rudp-provider-health",
|
||||
"daemon-published",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ describe("Heimdall Verse witness advertisement", () => {
|
|||
"youtube",
|
||||
"spotify",
|
||||
"joystick",
|
||||
"google",
|
||||
]);
|
||||
expect(advertisement.appProfiles.map((profile) => profile.slug)).toEqual([
|
||||
"repixelizer",
|
||||
|
|
|
|||
Loading…
Reference in a new issue