fix(deps): migrate SSO OIDC code to openid-client v6 (#492)

* chore(deps): bump the all-npm-backend group across 1 directory with 4 updates

Bumps the all-npm-backend group with 4 updates in the /backend directory: [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr), [openid-client](https://github.com/panva/openid-client), [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `@aws-sdk/client-ecr` from 3.1026.0 to 3.1028.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecr/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1028.0/clients/client-ecr)

Updates `openid-client` from 5.7.1 to 6.8.2
- [Release notes](https://github.com/panva/openid-client/releases)
- [Changelog](https://github.com/panva/openid-client/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/openid-client/compare/v5.7.1...v6.8.2)

Updates `@types/node` from 25.5.2 to 25.6.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `vitest` from 4.1.3 to 4.1.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/vitest)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-ecr"
  dependency-version: 3.1028.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: openid-client
  dependency-version: 6.8.2
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: all-npm-backend
- dependency-name: "@types/node"
  dependency-version: 25.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: vitest
  dependency-version: 4.1.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(deps): migrate SSO OIDC code to openid-client v6

The v5 Issuer/Client/generators API was removed upstream. Rewrite the
OIDC auth flow in SSOService to use the v6 functional API:

* discovery()/Configuration replace Issuer.discover + new issuer.Client
* buildAuthorizationUrl replaces client.authorizationUrl
* authorizationCodeGrant replaces client.callback, and handles the
  state check internally so the manual CSRF guard is removed
* fetchUserInfo replaces client.userinfo and now receives claims.sub
  so v6 can reject userinfo/id_token subject mismatches

Also:
* Cache the discovered Configuration per provider in CacheService
  (TTL 5 min) so a single login flow does not pay the HTTPS discovery
  round trip twice. Invalidation wired into saveProviderConfig,
  deleteProviderConfig, and seedOidcFromEnv via a private helper.
* Fix the testOidcDiscovery hack that passed a "discovery-probe"
  placeholder when clientId was missing; validate clientId upfront
  instead and return a clear error.
* Log fetchUserInfo failures at warn level before falling back to
  id_token claims so a subject-mismatch rejection is not silently
  hidden.

Unblocks dependabot PR #470.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Anso
2026-04-10 16:28:26 -04:00
committed by GitHub
parent af4b17cc37
commit 12fe79fc85
4 changed files with 175 additions and 163 deletions
+96 -61
View File
@@ -1,9 +1,25 @@
import crypto from 'crypto';
import { Client as LdapClient } from 'ldapts';
import { Issuer, Client as OIDCClient, generators } from 'openid-client';
import {
Configuration,
discovery,
buildAuthorizationUrl,
authorizationCodeGrant,
fetchUserInfo,
randomState,
randomPKCECodeVerifier,
calculatePKCECodeChallenge,
} from 'openid-client';
import { DatabaseService, User, AuthProvider } from './DatabaseService';
import { CryptoService } from './CryptoService';
import { LicenseService } from './LicenseService';
import { CacheService } from './CacheService';
// OIDC discovery metadata changes rarely; caching it eliminates the redundant
// HTTPS round-trip between getOIDCAuthorizationUrl and handleOIDCCallback in
// the same login flow, and across back-to-back logins.
const OIDC_CONFIG_TTL_MS = 5 * 60 * 1000;
const OIDC_CONFIG_NS = 'oidc-config';
export interface SSOProviderConfig {
provider: string;
@@ -121,6 +137,11 @@ export class SSOService {
configForStorage.oidcClientSecret = cryptoSvc.encrypt(configForStorage.oidcClientSecret);
}
db.upsertSSOConfig(provider, true, JSON.stringify(configForStorage));
this.invalidateOIDCConfigCache(provider);
}
private invalidateOIDCConfigCache(provider: string): void {
CacheService.getInstance().invalidate(`${OIDC_CONFIG_NS}:${provider}`);
}
// --- Config Management ---
@@ -173,10 +194,13 @@ export class SSOService {
config.enabled,
JSON.stringify(configForStorage)
);
// Client ID, secret, or issuer URL may have changed, so drop the cached Configuration.
this.invalidateOIDCConfigCache(config.provider);
}
public deleteProviderConfig(provider: string): void {
DatabaseService.getInstance().deleteSSOConfig(provider);
this.invalidateOIDCConfigCache(provider);
}
// --- LDAP Authentication ---
@@ -313,21 +337,22 @@ export class SSOService {
throw new Error(`SSO provider ${provider} is missing client ID`);
}
const { client } = await this.getOIDCClient(provider, config, callbackUrl);
const state = generators.state();
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
const oidcConfig = await this.getOIDCConfig(provider, config);
const state = randomState();
const codeVerifier = randomPKCECodeVerifier();
const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
const scopes = config.oidcScopes || 'openid email profile';
const url = client.authorizationUrl({
const url = buildAuthorizationUrl(oidcConfig, {
redirect_uri: callbackUrl,
scope: scopes,
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return { url, state, codeVerifier };
return { url: url.href, state, codeVerifier };
}
public async handleOIDCCallback(
@@ -337,39 +362,43 @@ export class SSOService {
expectedState: string,
codeVerifier: string
): Promise<SSOAuthResult> {
if (params.state !== expectedState) {
return { success: false, error: 'Invalid state parameter (possible CSRF attack)' };
}
const config = this.getProviderConfigDecrypted(provider);
if (!config || !config.enabled) {
return { success: false, error: `SSO provider ${provider} is not configured` };
}
try {
const { client } = await this.getOIDCClient(provider, config, callbackUrl);
const oidcConfig = await this.getOIDCConfig(provider, config);
const tokenSet = await client.callback(callbackUrl, { code: params.code, state: params.state }, {
state: expectedState,
code_verifier: codeVerifier,
const currentUrl = new URL(callbackUrl);
currentUrl.searchParams.set('code', params.code);
currentUrl.searchParams.set('state', params.state);
const tokens = await authorizationCodeGrant(oidcConfig, currentUrl, {
pkceCodeVerifier: codeVerifier,
expectedState,
});
let userInfo: Record<string, unknown>;
if (provider === 'oidc_github') {
// GitHub doesn't support standard OIDC userinfo; use their API
userInfo = await this.fetchGitHubUserInfo(tokenSet.access_token as string);
} else if (tokenSet.id_token) {
const claims = tokenSet.claims();
// Also fetch userinfo for complete profile
try {
const info = await client.userinfo(tokenSet.access_token as string);
userInfo = { ...claims, ...info };
} catch {
userInfo = claims as Record<string, unknown>;
}
userInfo = await this.fetchGitHubUserInfo(tokens.access_token);
} else {
userInfo = await client.userinfo(tokenSet.access_token as string) as Record<string, unknown>;
const claims = tokens.claims();
if (!claims) {
return { success: false, error: 'OIDC provider did not return an ID token' };
}
// Pass claims.sub so v6 rejects userinfo/id_token subject mismatches.
try {
const info = await fetchUserInfo(oidcConfig, tokens.access_token, String(claims.sub));
userInfo = { ...claims, ...info };
} catch (err) {
// Log so a subject-mismatch rejection is not silently hidden.
const message = err instanceof Error ? err.message : String(err);
console.warn(`[SSO] fetchUserInfo failed, falling back to id_token claims: ${message}`);
userInfo = { ...claims };
}
}
const sub = String(userInfo.sub || userInfo.id || '');
@@ -424,38 +453,37 @@ export class SSOService {
};
}
private async getOIDCClient(
private async getOIDCConfig(
provider: string,
config: SSOProviderConfig,
callbackUrl: string
): Promise<{ client: OIDCClient; issuer: InstanceType<typeof Issuer> }> {
let issuer: InstanceType<typeof Issuer>;
): Promise<Configuration> {
const clientId = config.oidcClientId || '';
const clientSecret = config.oidcClientSecret || undefined;
if (provider === 'oidc_github') {
// GitHub is not a standard OIDC provider - manually configure
issuer = new Issuer({
issuer: 'https://github.com',
authorization_endpoint: 'https://github.com/login/oauth/authorize',
token_endpoint: 'https://github.com/login/oauth/access_token',
userinfo_endpoint: 'https://api.github.com/user',
});
} else {
const issuerUrl = config.oidcIssuerUrl || WELL_KNOWN_ISSUERS[provider];
if (!issuerUrl) {
throw new Error(`Issuer URL not configured for ${provider}`);
}
issuer = await Issuer.discover(issuerUrl);
// GitHub is not a standard OIDC provider. Construct Configuration
// directly from known endpoints instead of going through discovery.
return new Configuration(
{
issuer: 'https://github.com',
authorization_endpoint: 'https://github.com/login/oauth/authorize',
token_endpoint: 'https://github.com/login/oauth/access_token',
userinfo_endpoint: 'https://api.github.com/user',
},
clientId,
clientSecret,
);
}
const client = new issuer.Client({
client_id: config.oidcClientId || '',
client_secret: config.oidcClientSecret || '',
redirect_uris: [callbackUrl],
response_types: ['code'],
token_endpoint_auth_method: 'client_secret_post',
});
return { client, issuer };
const issuerUrl = config.oidcIssuerUrl || WELL_KNOWN_ISSUERS[provider];
if (!issuerUrl) {
throw new Error(`Issuer URL not configured for ${provider}`);
}
return CacheService.getInstance().getOrFetch(
`${OIDC_CONFIG_NS}:${provider}`,
OIDC_CONFIG_TTL_MS,
() => discovery(new URL(issuerUrl), clientId, clientSecret),
);
}
private resolveRoleFromOidc(userInfo: Record<string, unknown>, config: SSOProviderConfig): 'admin' | 'viewer' {
@@ -568,17 +596,24 @@ export class SSOService {
if (!config) {
return { success: false, error: `Provider ${provider} not configured` };
}
if (provider === 'oidc_github') {
return { success: true, issuer: 'https://github.com (OAuth2, non-standard OIDC)' };
}
const issuerUrl = config.oidcIssuerUrl || WELL_KNOWN_ISSUERS[provider];
if (!issuerUrl) {
return { success: false, error: 'Issuer URL not configured' };
}
if (!config.oidcClientId) {
return { success: false, error: 'Client ID is required to test discovery' };
}
try {
if (provider === 'oidc_github') {
return { success: true, issuer: 'https://github.com (OAuth2, non-standard OIDC)' };
}
const issuerUrl = config.oidcIssuerUrl || WELL_KNOWN_ISSUERS[provider];
if (!issuerUrl) {
return { success: false, error: 'Issuer URL not configured' };
}
const issuer = await Issuer.discover(issuerUrl);
return { success: true, issuer: issuer.metadata.issuer };
const oidcConfig = await discovery(
new URL(issuerUrl),
config.oidcClientId,
config.oidcClientSecret || undefined,
);
return { success: true, issuer: oidcConfig.serverMetadata().issuer };
} catch (err) {
const message = err instanceof Error ? err.message : 'Discovery failed';
return { success: false, error: message };