chore: issue a token for guest entity ref

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
benjdlambert
2025-07-03 10:46:06 +02:00
parent bbda7485f6
commit 628322d19b
4 changed files with 327 additions and 10 deletions
@@ -23,6 +23,7 @@ import Router from 'express-promise-router';
import request from 'supertest';
import { OidcRouter } from './OidcRouter';
import { UserInfoDatabase } from '../database/UserInfoDatabase';
import { OidcDatabase } from '../database/OidcDatabase';
describe('OidcRouter', () => {
describe('/v1/userinfo', () => {
@@ -37,7 +38,12 @@ describe('OidcRouter', () => {
}),
} as unknown as UserInfoDatabase;
const mockOidc = {};
const mockOidc = {
createClient: jest.fn().mockResolvedValue({
clientId: 'test',
clientSecret: 'test',
}),
} as unknown as OidcDatabase;
const { server } = await startTestBackend({
features: [
@@ -55,6 +61,7 @@ describe('OidcRouter', () => {
tokenIssuer: {} as any,
baseUrl: 'http://localhost:7000',
userInfo: mockUserInfo,
oidc: mockOidc,
}).getRouter(),
);
httpRouter.use(router);
@@ -101,6 +108,13 @@ describe('OidcRouter', () => {
}),
} as unknown as UserInfoDatabase;
const mockOidc = {
createClient: jest.fn().mockResolvedValue({
clientId: 'test',
clientSecret: 'test',
}),
} as unknown as OidcDatabase;
const { server } = await startTestBackend({
features: [
createBackendPlugin({
@@ -117,6 +131,7 @@ describe('OidcRouter', () => {
tokenIssuer: {} as any,
baseUrl: 'http://localhost:7000',
userInfo: mockUserInfo,
oidc: mockOidc,
}).getRouter(),
);
httpRouter.use(router);
+111 -5
View File
@@ -19,7 +19,6 @@ import { AuthenticationError, isError } from '@backstage/errors';
import { AuthService } from '@backstage/backend-plugin-api';
import { TokenIssuer } from '../identity/types';
import { UserInfoDatabase } from '../database/UserInfoDatabase';
import { rest } from 'lodash';
import { OidcDatabase } from '../database/OidcDatabase';
export class OidcRouter {
@@ -47,11 +46,118 @@ export class OidcRouter {
res.json({ keys });
});
router.get('/v1/token', (_req, res) => {
res.status(501).send('Not Implemented');
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) {
return res.status(400).json({
error: 'invalid_request',
error_description:
'Missing required parameters: client_id, redirect_uri, response_type',
});
}
try {
// For simplicity, we'll use a default user entity ref for now
// In a real implementation, this should be obtained from the authenticated user
const userEntityRef = 'user:default/guest';
const { redirectUrl } = await this.oidc.authorize({
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,
userEntityRef,
});
return res.redirect(redirectUrl);
} 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());
}
});
// This endpoint doesn't use the regular HttpAuthoidc, since the contract
router.post('/v1/token', async (req, res) => {
// todo(blam): maybe add zod types for validating input
const {
grant_type: grantType,
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
} = req.body;
if (!grantType || !code || !clientId || !clientSecret || !redirectUri) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Missing required parameters',
});
}
try {
const result = await this.oidc.exchangeCodeForToken({
code,
clientId,
clientSecret,
redirectUri,
codeVerifier,
grantType,
});
return res.json(result);
} catch (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: isError(error) ? error.message : 'Unknown error',
});
}
});
// This endpoint doesn't use the regular HttpAuth, since the contract
// is specifically for the header to be communicated in the Authorization
// header, regardless of token type
router.get('/v1/userinfo', async (req, res) => {
@@ -71,7 +177,7 @@ export class OidcRouter {
res.json(userInfo);
});
router.get('/v1/register', async (req, res) => {
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) {
+196 -4
View File
@@ -16,10 +16,11 @@
import { AuthService } from '@backstage/backend-plugin-api';
import { TokenIssuer } from '../identity/types';
import { UserInfoDatabase } from '../database/UserInfoDatabase';
import { InputError } from '@backstage/errors';
import { InputError, AuthenticationError } from '@backstage/errors';
import { decodeJwt } from 'jose';
import crypto from 'crypto';
import { OidcDatabase } from '../database/OidcDatabase';
import { DateTime } from 'luxon';
export class OidcService {
private constructor(
@@ -52,7 +53,7 @@ export class OidcService {
token_endpoint: `${this.baseUrl}/v1/token`,
userinfo_endpoint: `${this.baseUrl}/v1/userinfo`,
jwks_uri: `${this.baseUrl}/.well-known/jwks.json`,
response_types_supported: ['id_token'],
response_types_supported: ['code', 'id_token'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: [
'RS256',
@@ -67,11 +68,15 @@ export class OidcService {
'EdDSA',
],
scopes_supported: ['openid'],
token_endpoint_auth_methods_supported: [],
token_endpoint_auth_methods_supported: [
'client_secret_basic',
'client_secret_post',
],
claims_supported: ['sub', 'ent'],
grant_types_supported: [],
grant_types_supported: ['authorization_code'],
authorization_endpoint: `${this.baseUrl}/v1/authorize`,
registration_endpoint: `${this.baseUrl}/v1/register`,
code_challenge_methods_supported: ['S256', 'plain'],
};
}
@@ -117,4 +122,191 @@ export class OidcService {
scope: opts.scope,
});
}
public async authorize(opts: {
clientId: string;
redirectUri: string;
responseType: string;
scope?: string;
state?: string;
nonce?: string;
codeChallenge?: string;
codeChallengeMethod?: string;
userEntityRef: string;
}) {
const {
clientId,
redirectUri,
responseType,
scope,
state,
nonce,
codeChallenge,
codeChallengeMethod,
userEntityRef,
} = opts;
if (responseType !== 'code') {
throw new InputError('Only authorization code flow is supported');
}
const client = await this.oidc.getClient({ clientId });
if (!client) {
throw new InputError('Invalid client_id');
}
if (!client.redirectUris.includes(redirectUri)) {
throw new InputError('Invalid redirect_uri');
}
if (codeChallenge) {
if (
!codeChallengeMethod ||
!['S256', 'plain'].includes(codeChallengeMethod)
) {
throw new InputError('Invalid code_challenge_method');
}
}
const authorizationCode = crypto.randomBytes(32).toString('base64url');
const expiresAt = DateTime.now().plus({ minutes: 10 }).toISO();
await this.oidc.createAuthorizationCode({
code: authorizationCode,
clientId,
userEntityRef,
redirectUri,
scope,
codeChallenge,
codeChallengeMethod,
nonce,
expiresAt,
});
const redirectUrl = new URL(redirectUri);
redirectUrl.searchParams.append('code', authorizationCode);
if (state) {
redirectUrl.searchParams.append('state', state);
}
return {
redirectUrl: redirectUrl.toString(),
};
}
public async exchangeCodeForToken(params: {
code: string;
clientId: string;
clientSecret: string;
redirectUri: string;
codeVerifier?: string;
grantType: string;
}) {
const {
code,
clientId,
clientSecret,
redirectUri,
codeVerifier,
grantType,
} = params;
if (grantType !== 'authorization_code') {
throw new InputError('Unsupported grant type');
}
const client = await this.oidc.getClient({ clientId });
if (!client) {
throw new AuthenticationError('Invalid client');
}
if (client.clientSecret !== clientSecret) {
throw new AuthenticationError('Invalid client credentials');
}
const authCode = await this.oidc.getAuthorizationCode({ code });
if (!authCode) {
throw new AuthenticationError('Invalid authorization code');
}
if (DateTime.fromISO(authCode.expiresAt) < DateTime.now()) {
throw new AuthenticationError('Authorization code expired');
}
if (authCode.used) {
throw new AuthenticationError('Authorization code already used');
}
if (authCode.clientId !== clientId) {
throw new AuthenticationError('Client ID mismatch');
}
if (authCode.redirectUri !== redirectUri) {
throw new AuthenticationError('Redirect URI mismatch');
}
if (authCode.codeChallenge) {
if (!codeVerifier) {
throw new AuthenticationError('Code verifier required for PKCE');
}
if (
!this.verifyPkce(
authCode.codeChallenge,
codeVerifier,
authCode.codeChallengeMethod,
)
) {
throw new AuthenticationError('Invalid code verifier');
}
}
await this.oidc.updateAuthorizationCode({
code,
used: true,
});
const accessTokenId = crypto.randomUUID();
const expiresAt = DateTime.now().plus({ hours: 1 }).toISO();
await this.oidc.createAccessToken({
tokenId: accessTokenId,
clientId,
userEntityRef: authCode.userEntityRef,
scope: authCode.scope,
expiresAt,
});
const { token } = await this.tokenIssuer.issueToken({
claims: {
sub: authCode.userEntityRef,
},
});
return {
access_token: token,
token_type: 'Bearer',
expires_in: 3600,
id_token: token,
scope: authCode.scope || 'openid',
};
}
private verifyPkce(
codeChallenge: string,
codeVerifier: string,
method?: string,
): boolean {
if (!method || method === 'plain') {
return codeChallenge === codeVerifier;
}
if (method === 'S256') {
const hash = crypto.createHash('sha256').update(codeVerifier).digest();
const base64urlHash = hash.toString('base64url');
return codeChallenge === base64urlHash;
}
return false;
}
}
@@ -40,6 +40,7 @@ import { StaticTokenIssuer } from '../identity/StaticTokenIssuer';
import { StaticKeyStore } from '../identity/StaticKeyStore';
import { bindProviderRouters, ProviderFactories } from '../providers/router';
import { OidcRouter } from './OidcRouter';
import { OidcDatabase } from '../database/OidcDatabase';
interface RouterOptions {
logger: LoggerService;
@@ -147,11 +148,14 @@ export async function createRouter(
userInfo,
});
const oidc = await OidcDatabase.create({ database });
const oidcRouter = OidcRouter.create({
auth: options.auth,
tokenIssuer,
baseUrl: authUrl,
userInfo,
oidc,
});
router.use(oidcRouter.getRouter());