Files
zooqueenandHanzo Dev ecee5e40aa Sign in through Hanzo IAM only, and make accounts for invited people only
The IAM callback at /auth/callback reads IAM's userinfo answer and refuses
an address IAM has not verified. An account is found by its linked IAM
identity alone (provider hanzo, the IAM subject); an existing account that
only shares the address is refused, never merged. A first sign-in makes an
account only for an admin of the org the application serves or for someone
an organisation invited at that verified address. Every refusal returns to
/signin with a sentence saying what to do.

Accepting an organisation invitation joins the signed-in person, and only
when their verified address is the invited one; opening the link no longer
adds whoever holds the address.

The local email/password sign-up, sign-in, reset, verify-email and update
routes, pages, jobs and mail are gone, as are passkey sign-in, the Google,
Microsoft and generic OIDC providers, the per-organisation SSO portal and
its account-link flow. Passkeys and two-factor stay for confirming a
signature. The e2e suite starts sessions the way a finished IAM sign-in
leaves them.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-16 01:52:24 -07:00

83 lines
2.5 KiB
TypeScript

import { z } from 'zod';
const ZOpenIdConfigurationSchema = z.object({
authorization_endpoint: z.string(),
token_endpoint: z.string(),
userinfo_endpoint: z.string(),
scopes_supported: z.array(z.string()).optional(),
});
type OpenIdConfiguration = z.infer<typeof ZOpenIdConfigurationSchema>;
type GetOpenIdConfigurationOptions = {
requiredScopes?: string[];
};
/**
* The provider's discovery document, remembered.
*
* Two endpoints are read out of it and both are settings of the identity
* provider — they change when someone deploys a new one, not between two
* sign-ins a second apart. Fetching it per request made every sign-in depend on
* a live round trip to the provider, so a single edge blip surfaced as
* `500 Failed to fetch OIDC configuration: Bad Gateway` with nothing to fall
* back on. Observed in production.
*
* So: serve from memory while fresh, and if a refresh fails, keep serving the
* last document that worked. Only a process that has never reached the provider
* can fail here, which is the one case where failing is the honest answer.
*/
const REMEMBER_FOR = 10 * 60 * 1000;
const REACH_WITHIN = 8_000;
const known = new Map<string, { at: number; config: OpenIdConfiguration }>();
const load = async (wellKnownUrl: string): Promise<OpenIdConfiguration> => {
const seen = known.get(wellKnownUrl);
if (seen && Date.now() - seen.at < REMEMBER_FOR) {
return seen.config;
}
try {
const response = await fetch(wellKnownUrl, { signal: AbortSignal.timeout(REACH_WITHIN) });
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
const config = ZOpenIdConfigurationSchema.parse(await response.json());
known.set(wellKnownUrl, { at: Date.now(), config });
return config;
} catch (err) {
if (seen) {
return seen.config;
}
throw new Error(
`Failed to fetch OIDC configuration: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
);
}
};
export const getOpenIdConfiguration = async (
wellKnownUrl: string,
options: GetOpenIdConfigurationOptions = {},
): Promise<OpenIdConfiguration> => {
const config = await load(wellKnownUrl);
const supportedScopes = config.scopes_supported ?? [];
const requiredScopes = options.requiredScopes ?? [];
const unsupportedScopes = requiredScopes.filter((scope) => !supportedScopes.includes(scope));
if (unsupportedScopes.length > 0) {
throw new Error(`Requested scopes not supported by provider: ${unsupportedScopes.join(', ')}`);
}
return config;
};