Google link is two-phase; callback evaluates all linked identities
The callback records a typed pending link (short TTL, single use) and writes no linked identity; POST /v1/apps/:appSlug/links/confirm with the Heimdall access token of the initiating account attaches it. A victim who completes an attacker's link URL attaches nothing. The sign-in callback also evaluates the account's other linked identities with the start-time policy, so a Discord-origin member returning via Google keeps app_access. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8e870d20a0
commit
c829429cd5
10 changed files with 513 additions and 47 deletions
|
|
@ -203,6 +203,17 @@ door into a person who already has one.
|
|||
nothing. A `sub` linked to another account is refused and never moved.
|
||||
Both audit as `oauth_callback_failed` with error class `AccountLinkRefused`
|
||||
and the code `identity_not_linked` or `identity_linked_elsewhere`.
|
||||
- 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.
|
||||
|
|
|
|||
192
src/app.ts
192
src/app.ts
|
|
@ -415,6 +415,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) {
|
||||
|
|
@ -424,7 +426,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;
|
||||
}
|
||||
|
||||
|
|
@ -1160,6 +1162,99 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
throw new AccountLinkRefused("identity_not_linked");
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
const createAccountInput: CreateAccountInput = {
|
||||
createdAt: nowIso,
|
||||
|
|
@ -1185,32 +1280,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,
|
||||
|
|
@ -1226,13 +1296,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) {
|
||||
|
|
@ -1495,6 +1579,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);
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
@ -424,6 +427,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,
|
||||
|
|
@ -88,6 +90,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;
|
||||
|
|
@ -231,6 +246,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,
|
||||
|
|
@ -378,6 +411,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"],
|
||||
|
|
@ -1061,6 +1095,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,
|
||||
|
|
|
|||
|
|
@ -182,6 +182,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;
|
||||
|
|
@ -271,6 +293,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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createHash, createSign, generateKeyPairSync } from "node:crypto";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
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";
|
||||
|
|
@ -24,6 +24,7 @@ function config(): HeimdallConfig {
|
|||
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" },
|
||||
|
|
@ -40,8 +41,9 @@ function discordRuntime(providerUserId: string, name: string): OAuthProviderRunt
|
|||
async resolveIdentity() {
|
||||
return { provider: "discord", providerUserId, username: name, displayName: name, profile: { id: providerUserId } };
|
||||
},
|
||||
async evaluateEntitlements() {
|
||||
return { facts: [], snapshots: [] };
|
||||
// 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: [] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -139,6 +141,35 @@ 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();
|
||||
|
|
@ -174,8 +205,14 @@ describe("Google as a Heimdall OIDC provider", () => {
|
|||
|
||||
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().account.id).toBe(accountId);
|
||||
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");
|
||||
|
|
@ -192,10 +229,7 @@ describe("Google as a Heimdall OIDC provider", () => {
|
|||
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);
|
||||
const link = await startGoogle(app, accessToken);
|
||||
const linkUrl = new URL(link.json().authorizationUrl);
|
||||
fakeGoogle({ nonceFrom: () => linkUrl.searchParams.get("nonce") ?? undefined });
|
||||
expect((await callbackGoogle(app, link.json().stateToken)).statusCode).toBe(201);
|
||||
await linkGoogle(app, accessToken);
|
||||
|
||||
const signIn = await startGoogle(app, undefined, "sign_in");
|
||||
expect(signIn.statusCode).toBe(201);
|
||||
|
|
@ -259,10 +293,7 @@ describe("Google as a Heimdall OIDC provider", () => {
|
|||
const store = new InMemoryStore();
|
||||
const first = await build(store, "discord-1");
|
||||
const a = await signInWithDiscord(first.app, "discord-1");
|
||||
const startA = await startGoogle(first.app, a.accessToken);
|
||||
const urlA = new URL(startA.json().authorizationUrl);
|
||||
fakeGoogle({ nonceFrom: () => urlA.searchParams.get("nonce") ?? undefined });
|
||||
expect((await callbackGoogle(first.app, startA.json().stateToken)).statusCode).toBe(201);
|
||||
await linkGoogle(first.app, a.accessToken);
|
||||
|
||||
// A second person, a second account, the same Google subject.
|
||||
const second = await build(store, "discord-2");
|
||||
|
|
@ -299,14 +330,152 @@ describe("Google as a Heimdall OIDC provider", () => {
|
|||
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);
|
||||
const start = await startGoogle(app, accessToken);
|
||||
const url = new URL(start.json().authorizationUrl);
|
||||
fakeGoogle({ nonceFrom: () => url.searchParams.get("nonce") ?? undefined, claims: { sub: "g-sub-2" } });
|
||||
await callbackGoogle(app, start.json().stateToken);
|
||||
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(502);
|
||||
});
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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(85);
|
||||
expect(REQUIRED_COLUMNS).toHaveLength(95);
|
||||
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: "" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue