Phase 19: Local Auth (No-OIDC Mode) #23
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-11
@@ -41,6 +41,7 @@ import {
|
|||||||
CredentialValidationError,
|
CredentialValidationError,
|
||||||
} from '../broker/credentialSync.js';
|
} from '../broker/credentialSync.js';
|
||||||
import { hashPassword, verifyPassword } from '../auth/localCredentials.js';
|
import { hashPassword, verifyPassword } from '../auth/localCredentials.js';
|
||||||
|
import { resolveOidcConfig, discoverAuthorizationEndpoint } from '../auth/oidcConfig.js';
|
||||||
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
||||||
import '../auth/devBypass.js';
|
import '../auth/devBypass.js';
|
||||||
|
|
||||||
@@ -324,23 +325,26 @@ meRouter.post('/link-oidc', async (c) => {
|
|||||||
'HS256',
|
'HS256',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build the OIDC authorization URL if OIDC is configured (else return null — PWA disables button)
|
// Build the OIDC authorization URL if OIDC is configured (else return null — PWA disables button).
|
||||||
const issuer = process.env.OIDC_ISSUER ?? null;
|
// WR-04: resolve issuer/clientId/redirectUri from env-OR-app_config (single source of truth,
|
||||||
const clientId = process.env.OIDC_CLIENT_ID ?? null;
|
// consistent with /api/auth/mode and the OIDC fallback middleware) so a wizard-configured
|
||||||
const redirectUri = process.env.OIDC_REDIRECT_URI ?? null;
|
// instance does not report oidcEnabled:true while returning authorizationUrl:null here.
|
||||||
|
// WR-02: discover the authorization_endpoint from the provider's discovery document instead
|
||||||
|
// of hardcoding Authelia's /api/oidc/authorization path.
|
||||||
let authorizationUrl: string | null = null;
|
let authorizationUrl: string | null = null;
|
||||||
if (issuer && clientId && redirectUri) {
|
const oidc = await resolveOidcConfig();
|
||||||
// Construct the authorization URL. plan 19-03 will handle the full PKCE flow;
|
if (oidc) {
|
||||||
// for now encode the signed state so the callback can read linkUserId.
|
const authEndpoint = await discoverAuthorizationEndpoint(oidc.issuer);
|
||||||
const url = new URL(`${issuer.replace(/\/$/, '')}/api/oidc/authorization`);
|
if (authEndpoint) {
|
||||||
|
const url = new URL(authEndpoint);
|
||||||
url.searchParams.set('response_type', 'code');
|
url.searchParams.set('response_type', 'code');
|
||||||
url.searchParams.set('client_id', clientId);
|
url.searchParams.set('client_id', oidc.clientId);
|
||||||
url.searchParams.set('redirect_uri', redirectUri);
|
url.searchParams.set('redirect_uri', oidc.redirectUri);
|
||||||
url.searchParams.set('scope', 'openid profile email');
|
url.searchParams.set('scope', 'openid profile email');
|
||||||
url.searchParams.set('state', signedState);
|
url.searchParams.set('state', signedState);
|
||||||
authorizationUrl = url.toString();
|
authorizationUrl = url.toString();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({ signedState, authorizationUrl }, 200);
|
return c.json({ signedState, authorizationUrl }, 200);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user