chore: wrap up things in a feature flag

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
benjdlambert
2025-09-08 14:27:05 +02:00
parent 025fdd20ea
commit 225cdf5bdf
5 changed files with 328 additions and 309 deletions
@@ -86,6 +86,7 @@ describe('OidcRouter', () => {
userInfo: userInfoDatabase,
oidc: oidcDatabase,
httpAuth: mockHttpAuth,
enableDynamicClientRegistration: true,
});
return {
+300 -295
View File
@@ -33,6 +33,7 @@ export class OidcRouter {
private readonly auth: AuthService,
private readonly appUrl: string,
private readonly httpAuth: HttpAuthService,
private readonly enableDynamicClientRegistration: boolean,
) {}
static create(options: {
@@ -44,6 +45,7 @@ export class OidcRouter {
userInfo: UserInfoDatabase;
oidc: OidcDatabase;
httpAuth: HttpAuthService;
enableDynamicClientRegistration: boolean;
}) {
return new OidcRouter(
OidcService.create(options),
@@ -51,6 +53,7 @@ export class OidcRouter {
options.auth,
options.appUrl,
options.httpAuth,
options.enableDynamicClientRegistration,
);
}
@@ -74,266 +77,6 @@ export class OidcRouter {
res.json({ keys });
});
// Authorization endpoint
// https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
// Handles the initial authorization request from the client, validates parameters,
// and redirects to the Authorization Session page for user approval
router.get('/v1/authorize', async (req, res) => {
// todo(blam): maybe add zod types for validating input
const {
client_id: clientId,
redirect_uri: redirectUri,
response_type: responseType,
scope,
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
} = req.query;
if (!clientId || !redirectUri || !responseType) {
this.logger.error(`Failed to authorize: Missing required parameters`);
return res.status(400).json({
error: 'invalid_request',
error_description:
'Missing required parameters: client_id, redirect_uri, response_type',
});
}
try {
const result = await this.oidc.createAuthorizationSession({
clientId: clientId as string,
redirectUri: redirectUri as string,
responseType: responseType as string,
scope: scope as string,
state: state as string,
nonce: nonce as string,
codeChallenge: codeChallenge as string,
codeChallengeMethod: codeChallengeMethod as string,
});
// todo(blam): maybe this URL could be overridable by config if
// the plugin is mounted somewhere else?
// support slashes in baseUrl?
const authSessionRedirectUrl = new URL(
`/auth/sessions/${result.id}`,
this.appUrl,
);
return res.redirect(authSessionRedirectUrl.toString());
} catch (error) {
const errorParams = new URLSearchParams();
errorParams.append(
'error',
isError(error) ? error.name : 'server_error',
);
errorParams.append(
'error_description',
isError(error) ? error.message : 'Unknown error',
);
if (state) {
errorParams.append('state', state as string);
}
const redirectUrl = new URL(redirectUri as string);
redirectUrl.search = errorParams.toString();
return res.redirect(redirectUrl.toString());
}
});
// Authorization Session request details endpoint
// Returns Authorization Session request details for the frontned
router.get('/v1/sessions/:sessionId', async (req, res) => {
const { sessionId } = req.params;
if (!sessionId) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Missing Authorization Session ID',
});
}
try {
const session = await this.oidc.getAuthorizationSession({
sessionId,
});
return res.json({
id: session.id,
clientName: session.clientName,
scope: session.scope,
redirectUri: session.redirectUri,
});
} catch (error) {
const description = isError(error) ? error.message : 'Unknown error';
this.logger.error(
`Failed to get authorization session: ${description}`,
error,
);
return res.status(404).json({
error: 'not_found',
error_description: description,
});
}
});
// Authorization Session approval endpoint
// Handles user approval of Authorization Session requests and generates authorization codes
router.post('/v1/sessions/:sessionId/approve', async (req, res) => {
const { sessionId } = req.params;
if (!sessionId) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Missing authorization session ID',
});
}
try {
const httpCredentials = await this.httpAuth.credentials(req);
if (!this.auth.isPrincipal(httpCredentials, 'user')) {
return res.status(401).json({
error: 'unauthorized',
error_description: 'Authentication required',
});
}
const userEntityRef = httpCredentials.principal.userEntityRef;
const result = await this.oidc.approveAuthorizationSession({
sessionId,
userEntityRef,
});
return res.json({
redirectUrl: result.redirectUrl,
});
} catch (error) {
const description = isError(error) ? error.message : 'Unknown error';
this.logger.error(
`Failed to approve authorization session: ${description}`,
error,
);
return res.status(400).json({
error: 'invalid_request',
error_description: description,
});
}
});
// Authorization Session rejection endpoint
// Handles user rejection of Authorization Session requests and redirects with error
router.post('/v1/sessions/:sessionId/reject', async (req, res) => {
const { sessionId } = req.params;
if (!sessionId) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Missing authorization session ID',
});
}
try {
const session = await this.oidc.getAuthorizationSession({
sessionId,
});
await this.oidc.rejectAuthorizationSession({ sessionId });
const errorParams = new URLSearchParams();
errorParams.append('error', 'access_denied');
errorParams.append('error_description', 'User denied the request');
if (session.state) {
errorParams.append('state', session.state);
}
const redirectUrl = new URL(session.redirectUri);
redirectUrl.search = errorParams.toString();
return res.json({
redirectUrl: redirectUrl.toString(),
});
} catch (error) {
const description = isError(error) ? error.message : 'Unknown error';
this.logger.error(
`Failed to reject authorization session: ${description}`,
error,
);
return res.status(400).json({
error: 'invalid_request',
error_description: description,
});
}
});
// Token endpoint
// https://openid.net/specs/openid-connect-core-1_0.html#TokenRequest
// Exchanges authorization codes for access tokens and ID tokens
router.post('/v1/token', async (req, res) => {
// todo(blam): maybe add zod types for validating input
const {
grant_type: grantType,
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
} = req.body;
if (!grantType || !code || !redirectUri) {
this.logger.error(
`Failed to exchange code for token: Missing required parameters`,
);
return res.status(400).json({
error: 'invalid_request',
error_description: 'Missing required parameters',
});
}
try {
const result = await this.oidc.exchangeCodeForToken({
code,
redirectUri,
codeVerifier,
grantType,
});
return res.json({
access_token: result.accessToken,
token_type: result.tokenType,
expires_in: result.expiresIn,
id_token: result.idToken,
scope: result.scope,
});
} catch (error) {
const description = isError(error) ? error.message : 'Unknown error';
this.logger.error(
`Failed to exchange code for token: ${description}`,
error,
);
if (isError(error)) {
if (error.name === 'AuthenticationError') {
return res.status(401).json({
error: 'invalid_client',
error_description: error.message,
});
}
if (error.name === 'InputError') {
return res.status(400).json({
error: 'invalid_request',
error_description: error.message,
});
}
}
return res.status(500).json({
error: 'server_error',
error_description: description,
});
}
});
// UserInfo endpoint
// https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
// Returns claims about the authenticated user using an access token
@@ -354,45 +97,307 @@ export class OidcRouter {
res.json(userInfo);
});
// Dynamic Client Registration endpoint
// https://openid.net/specs/openid-connect-registration-1_0.html#ClientRegistration
// Allows clients to register themselves dynamically with the provider
router.post('/v1/register', async (req, res) => {
// todo(blam): maybe add zod types for validating input
const registrationRequest = req.body;
if (this.enableDynamicClientRegistration) {
// Authorization endpoint
// https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
// Handles the initial authorization request from the client, validates parameters,
// and redirects to the Authorization Session page for user approval
router.get('/v1/authorize', async (req, res) => {
// todo(blam): maybe add zod types for validating input
const {
client_id: clientId,
redirect_uri: redirectUri,
response_type: responseType,
scope,
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
} = req.query;
if (!registrationRequest.redirect_uris?.length) {
res.status(400).json({
error: 'invalid_request',
error_description: 'redirect_uris is required',
});
return;
}
if (!clientId || !redirectUri || !responseType) {
this.logger.error(`Failed to authorize: Missing required parameters`);
return res.status(400).json({
error: 'invalid_request',
error_description:
'Missing required parameters: client_id, redirect_uri, response_type',
});
}
try {
const client = await this.oidc.registerClient({
clientName: registrationRequest.client_name,
redirectUris: registrationRequest.redirect_uris,
responseTypes: registrationRequest.response_types,
grantTypes: registrationRequest.grant_types,
scope: registrationRequest.scope,
});
try {
const result = await this.oidc.createAuthorizationSession({
clientId: clientId as string,
redirectUri: redirectUri as string,
responseType: responseType as string,
scope: scope as string,
state: state as string,
nonce: nonce as string,
codeChallenge: codeChallenge as string,
codeChallengeMethod: codeChallengeMethod as string,
});
res.status(201).json({
client_id: client.clientId,
redirect_uris: client.redirectUris,
client_secret: client.clientSecret,
});
} catch (e) {
const description = isError(e) ? e.message : 'Unknown error';
this.logger.error(`Failed to register client: ${description}`, e);
// todo(blam): maybe this URL could be overridable by config if
// the plugin is mounted somewhere else?
// support slashes in baseUrl?
const authSessionRedirectUrl = new URL(
`/auth/sessions/${result.id}`,
this.appUrl,
);
res.status(500).json({
error: 'server_error',
error_description: `Failed to register client: ${description}`,
});
}
});
return res.redirect(authSessionRedirectUrl.toString());
} catch (error) {
const errorParams = new URLSearchParams();
errorParams.append(
'error',
isError(error) ? error.name : 'server_error',
);
errorParams.append(
'error_description',
isError(error) ? error.message : 'Unknown error',
);
if (state) {
errorParams.append('state', state as string);
}
const redirectUrl = new URL(redirectUri as string);
redirectUrl.search = errorParams.toString();
return res.redirect(redirectUrl.toString());
}
});
// Authorization Session request details endpoint
// Returns Authorization Session request details for the frontned
router.get('/v1/sessions/:sessionId', async (req, res) => {
const { sessionId } = req.params;
if (!sessionId) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Missing Authorization Session ID',
});
}
try {
const session = await this.oidc.getAuthorizationSession({
sessionId,
});
return res.json({
id: session.id,
clientName: session.clientName,
scope: session.scope,
redirectUri: session.redirectUri,
});
} catch (error) {
const description = isError(error) ? error.message : 'Unknown error';
this.logger.error(
`Failed to get authorization session: ${description}`,
error,
);
return res.status(404).json({
error: 'not_found',
error_description: description,
});
}
});
// Authorization Session approval endpoint
// Handles user approval of Authorization Session requests and generates authorization codes
router.post('/v1/sessions/:sessionId/approve', async (req, res) => {
const { sessionId } = req.params;
if (!sessionId) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Missing authorization session ID',
});
}
try {
const httpCredentials = await this.httpAuth.credentials(req);
if (!this.auth.isPrincipal(httpCredentials, 'user')) {
return res.status(401).json({
error: 'unauthorized',
error_description: 'Authentication required',
});
}
const userEntityRef = httpCredentials.principal.userEntityRef;
const result = await this.oidc.approveAuthorizationSession({
sessionId,
userEntityRef,
});
return res.json({
redirectUrl: result.redirectUrl,
});
} catch (error) {
const description = isError(error) ? error.message : 'Unknown error';
this.logger.error(
`Failed to approve authorization session: ${description}`,
error,
);
return res.status(400).json({
error: 'invalid_request',
error_description: description,
});
}
});
// Authorization Session rejection endpoint
// Handles user rejection of Authorization Session requests and redirects with error
router.post('/v1/sessions/:sessionId/reject', async (req, res) => {
const { sessionId } = req.params;
if (!sessionId) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Missing authorization session ID',
});
}
try {
const session = await this.oidc.getAuthorizationSession({
sessionId,
});
await this.oidc.rejectAuthorizationSession({ sessionId });
const errorParams = new URLSearchParams();
errorParams.append('error', 'access_denied');
errorParams.append('error_description', 'User denied the request');
if (session.state) {
errorParams.append('state', session.state);
}
const redirectUrl = new URL(session.redirectUri);
redirectUrl.search = errorParams.toString();
return res.json({
redirectUrl: redirectUrl.toString(),
});
} catch (error) {
const description = isError(error) ? error.message : 'Unknown error';
this.logger.error(
`Failed to reject authorization session: ${description}`,
error,
);
return res.status(400).json({
error: 'invalid_request',
error_description: description,
});
}
});
// Token endpoint
// https://openid.net/specs/openid-connect-core-1_0.html#TokenRequest
// Exchanges authorization codes for access tokens and ID tokens
router.post('/v1/token', async (req, res) => {
// todo(blam): maybe add zod types for validating input
const {
grant_type: grantType,
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
} = req.body;
if (!grantType || !code || !redirectUri) {
this.logger.error(
`Failed to exchange code for token: Missing required parameters`,
);
return res.status(400).json({
error: 'invalid_request',
error_description: 'Missing required parameters',
});
}
try {
const result = await this.oidc.exchangeCodeForToken({
code,
redirectUri,
codeVerifier,
grantType,
});
return res.json({
access_token: result.accessToken,
token_type: result.tokenType,
expires_in: result.expiresIn,
id_token: result.idToken,
scope: result.scope,
});
} catch (error) {
const description = isError(error) ? error.message : 'Unknown error';
this.logger.error(
`Failed to exchange code for token: ${description}`,
error,
);
if (isError(error)) {
if (error.name === 'AuthenticationError') {
return res.status(401).json({
error: 'invalid_client',
error_description: error.message,
});
}
if (error.name === 'InputError') {
return res.status(400).json({
error: 'invalid_request',
error_description: error.message,
});
}
}
return res.status(500).json({
error: 'server_error',
error_description: description,
});
}
});
// Dynamic Client Registration endpoint
// https://openid.net/specs/openid-connect-registration-1_0.html#ClientRegistration
// Allows clients to register themselves dynamically with the provider
router.post('/v1/register', async (req, res) => {
// todo(blam): maybe add zod types for validating input
const registrationRequest = req.body;
if (!registrationRequest.redirect_uris?.length) {
res.status(400).json({
error: 'invalid_request',
error_description: 'redirect_uris is required',
});
return;
}
try {
const client = await this.oidc.registerClient({
clientName: registrationRequest.client_name,
redirectUris: registrationRequest.redirect_uris,
responseTypes: registrationRequest.response_types,
grantTypes: registrationRequest.grant_types,
scope: registrationRequest.scope,
});
res.status(201).json({
client_id: client.clientId,
redirect_uris: client.redirectUris,
client_secret: client.clientSecret,
});
} catch (e) {
const description = isError(e) ? e.message : 'Unknown error';
this.logger.error(`Failed to register client: ${description}`, e);
res.status(500).json({
error: 'server_error',
error_description: `Failed to register client: ${description}`,
});
}
});
}
return router;
}
@@ -162,6 +162,10 @@ export async function createRouter(
oidc,
logger,
httpAuth,
enableDynamicClientRegistration:
config.getOptionalBoolean(
'auth.experimental.enableDynamicClientRegistration',
) ?? false,
});
router.use(oidcRouter.getRouter());