mirror of
https://github.com/hanzoai/sign.git
synced 2026-10-21 01:21:51 +00:00
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]>
83 lines
2.5 KiB
TypeScript
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;
|
|
};
|