feat: add oauth2 code verifier

* fix: ensure oauth state param matches before finishing oauth flow

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>

* chore: upgrade openid-client to v6

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>

* feat: use PKCE for oauth2 on supported clients

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>

* feat: use state and PKCE in mobile app

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>

* fix: remove obsolete oauth repository init

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>

* fix: rewrite callback url if mobile redirect url is enabled

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>

* fix: propagate oidc client error cause when oauth callback fails

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>

* fix: adapt auth service tests to required state and PKCE params

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>

* fix: update sdk types

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>

* fix: adapt oauth e2e test to work with PKCE

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>

* fix: allow insecure (http) oauth clients

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>

---------

Signed-off-by: Tin Pecirep <tin.pecirep@gmail.com>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
Tin Pecirep
2025-04-23 16:05:00 +02:00
committed by Zack Pollard
parent 13d6bd67b1
commit b7a0cf2470
18 changed files with 469 additions and 192 deletions

View File

@@ -1,5 +1,5 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { custom, generators, Issuer, UserinfoResponse } from 'openid-client';
import type { UserInfoResponse } from 'openid-client' with { 'resolution-mode': 'import' };
import { LoggingRepository } from 'src/repositories/logging.repository';
export type OAuthConfig = {
@@ -12,7 +12,7 @@ export type OAuthConfig = {
scope: string;
signingAlgorithm: string;
};
export type OAuthProfile = UserinfoResponse;
export type OAuthProfile = UserInfoResponse;
@Injectable()
export class OAuthRepository {
@@ -20,30 +20,47 @@ export class OAuthRepository {
this.logger.setContext(OAuthRepository.name);
}
init() {
custom.setHttpOptionsDefaults({ timeout: 30_000 });
}
async authorize(config: OAuthConfig, redirectUrl: string) {
async authorize(config: OAuthConfig, redirectUrl: string, state?: string, codeChallenge?: string) {
const { buildAuthorizationUrl, randomState, randomPKCECodeVerifier, calculatePKCECodeChallenge } = await import(
'openid-client'
);
const client = await this.getClient(config);
return client.authorizationUrl({
state ??= randomState();
let codeVerifier: string | null;
if (codeChallenge) {
codeVerifier = null;
} else {
codeVerifier = randomPKCECodeVerifier();
codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
}
const url = buildAuthorizationUrl(client, {
redirect_uri: redirectUrl,
scope: config.scope,
state: generators.state(),
});
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
}).toString();
return { url, state, codeVerifier };
}
async getLogoutEndpoint(config: OAuthConfig) {
const client = await this.getClient(config);
return client.issuer.metadata.end_session_endpoint;
return client.serverMetadata().end_session_endpoint;
}
async getProfile(config: OAuthConfig, url: string, redirectUrl: string): Promise<OAuthProfile> {
async getProfile(
config: OAuthConfig,
url: string,
expectedState: string,
codeVerifier: string,
): Promise<OAuthProfile> {
const { authorizationCodeGrant, fetchUserInfo, ...oidc } = await import('openid-client');
const client = await this.getClient(config);
const params = client.callbackParams(url);
const pkceCodeVerifier = client.serverMetadata().supportsPKCE() ? codeVerifier : undefined;
try {
const tokens = await client.callback(redirectUrl, params, { state: params.state });
const profile = await client.userinfo<OAuthProfile>(tokens.access_token || '');
const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier });
const profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck);
if (!profile.sub) {
throw new Error('Unexpected profile response, no `sub`');
}
@@ -59,6 +76,11 @@ export class OAuthRepository {
);
}
if (error.code === 'OAUTH_INVALID_RESPONSE') {
this.logger.warn(`Invalid response from authorization server. Cause: ${error.cause?.message}`);
throw error.cause;
}
throw error;
}
}
@@ -83,14 +105,20 @@ export class OAuthRepository {
signingAlgorithm,
}: OAuthConfig) {
try {
const issuer = await Issuer.discover(issuerUrl);
return new issuer.Client({
client_id: clientId,
client_secret: clientSecret,
response_types: ['code'],
userinfo_signed_response_alg: profileSigningAlgorithm === 'none' ? undefined : profileSigningAlgorithm,
id_token_signed_response_alg: signingAlgorithm,
});
const { allowInsecureRequests, discovery } = await import('openid-client');
return await discovery(
new URL(issuerUrl),
clientId,
{
client_secret: clientSecret,
response_types: ['code'],
userinfo_signed_response_alg: profileSigningAlgorithm === 'none' ? undefined : profileSigningAlgorithm,
id_token_signed_response_alg: signingAlgorithm,
timeout: 30_000,
},
undefined,
{ execute: [allowInsecureRequests] },
);
} catch (error: any | AggregateError) {
this.logger.error(`Error in OAuth discovery: ${error}`, error?.stack, error?.errors);
throw new InternalServerErrorException(`Error in OAuth discovery: ${error}`, { cause: error });