Files
familysync/apps/api/src/auth/oidcConfig.ts
T

92 lines
3.8 KiB
TypeScript

/**
* oidcConfig.ts — centralized "is OIDC configured" resolution + endpoint discovery.
*
* WR-04: three sites previously had independent notions of whether OIDC is configured:
* - routes/authMode.ts → OIDC_ISSUER env OR app_config.oidc_issuer
* - auth/middleware.ts → injects issuer/client-id/external-url from app_config
* - routes/me.ts (link-oidc) → ONLY env (OIDC_ISSUER && OIDC_CLIENT_ID && OIDC_REDIRECT_URI)
*
* The me.ts divergence meant a wizard-configured-but-not-restarted instance reported
* oidcEnabled:true from /api/auth/mode (and showed the "Link OIDC" button) but link-oidc
* returned authorizationUrl:null. This helper makes the env-OR-app_config resolution a
* single source of truth.
*
* WR-02: me.ts also hardcoded Authelia's `/api/oidc/authorization` path. The project's
* design is to discover endpoints from the issuer's /.well-known/openid-configuration
* document (the whole reason @hono/oidc-auth is used). discoverAuthorizationEndpoint()
* resolves the real authorization_endpoint so any RFC-compliant provider works.
*/
import { eq } from 'drizzle-orm';
import { db } from '../db/client.js';
import { appConfig } from '../db/schema.js';
export interface ResolvedOidcConfig {
issuer: string;
clientId: string;
redirectUri: string;
}
/**
* Read a single app_config value (or null when absent).
*/
async function readAppConfig(key: string): Promise<string | null> {
const [row] = await db
.select({ value: appConfig.value })
.from(appConfig)
.where(eq(appConfig.key, key))
.limit(1);
return row?.value ?? null;
}
/**
* Resolve issuer + clientId + redirectUri using env first, then app_config (WR-04).
*
* Returns null when any of the three cannot be resolved from EITHER source — i.e. OIDC is
* not (fully) configured, so no authorization URL can be built. The redirect URI is taken
* from OIDC_REDIRECT_URI when present, else derived from the external app URL
* (OIDC_AUTH_EXTERNAL_URL env or app_config.app_external_url) as `${externalUrl}/callback`,
* matching the middleware's redirect-uri construction.
*/
export async function resolveOidcConfig(): Promise<ResolvedOidcConfig | null> {
const issuer = process.env.OIDC_ISSUER ?? (await readAppConfig('oidc_issuer'));
const clientId = process.env.OIDC_CLIENT_ID ?? (await readAppConfig('oidc_client_id'));
let redirectUri = process.env.OIDC_REDIRECT_URI ?? null;
if (!redirectUri) {
const externalUrl =
process.env.OIDC_AUTH_EXTERNAL_URL ?? (await readAppConfig('app_external_url'));
if (externalUrl) {
redirectUri = `${externalUrl.replace(/\/$/, '')}/callback`;
}
}
if (!issuer || !clientId || !redirectUri) return null;
return { issuer, clientId, redirectUri };
}
/**
* Discover the provider's authorization_endpoint from its OIDC discovery document (WR-02).
*
* Fetches `${issuer}/.well-known/openid-configuration` (5s timeout, matching the setup
* wizard's validate/oidc step) and returns the `authorization_endpoint` URL. Returns null
* on any network error, non-2xx, or missing field — callers treat null as "cannot build
* the authorization URL" and degrade gracefully (the PWA disables the Link button).
*/
export async function discoverAuthorizationEndpoint(issuer: string): Promise<string | null> {
try {
const res = await fetch(`${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return null;
const doc = (await res.json()) as { authorization_endpoint?: unknown };
return typeof doc.authorization_endpoint === 'string' ? doc.authorization_endpoint : null;
} catch (err) {
console.error(
'[oidcConfig/discoverAuthorizationEndpoint]',
err instanceof Error ? err.message : String(err),
);
return null;
}
}