chore: issue a token for guest entity ref
Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user