feat(core): improve auth handling (#15271)

fix #15270
fix #15260
fix #15257


#### PR Dependency Tree


* **PR #15271** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
DarkSky
2026-07-18 06:27:01 +08:00
committed by GitHub
parent 427db39862
commit d24c17f300
17 changed files with 273 additions and 99 deletions
@@ -18,6 +18,7 @@ export type OIDCArgs = {
export interface OAuthOIDCProviderConfig extends OAuthProviderConfig {
issuer: string;
allowPrivateNetwork?: boolean;
args?: OIDCArgs;
}
@@ -49,6 +50,22 @@ const schema: JSONSchema = {
},
};
const oidcSchema: JSONSchema = {
type: 'object',
properties: {
...schema.properties,
issuer: {
type: 'string',
description: 'OIDC issuer URL',
},
allowPrivateNetwork: {
type: 'boolean',
description:
'Allow the OIDC issuer origin to resolve to private network addresses',
},
},
};
defineModuleConfig('oauth', {
'providers.google': {
desc: 'Google OAuth provider config',
@@ -69,14 +86,15 @@ defineModuleConfig('oauth', {
link: 'https://docs.github.com/en/apps/oauth-apps',
},
'providers.oidc': {
desc: 'OIDC OAuth provider config',
desc: 'OIDC OAuth provider config. Private network access requires allowPrivateNetwork: true',
default: {
clientId: '',
clientSecret: '',
issuer: '',
allowPrivateNetwork: false,
args: {},
},
schema,
schema: oidcSchema,
link: 'https://openid.net/specs/openid-connect-core-1_0.html',
shape: z.object({
issuer: z
@@ -84,6 +102,7 @@ defineModuleConfig('oauth', {
.url()
.regex(/^https?:\/\//, 'issuer must be a valid URL')
.or(z.string().length(0)),
allowPrivateNetwork: z.boolean().optional(),
args: z.object({
scope: z.string().optional(),
claim_id: z.string().optional(),
@@ -6,6 +6,7 @@ import {
InvalidOauthResponse,
OnEvent,
safeFetch,
type SafeFetchOptions,
} from '../../../base';
import { OAuthProviderName } from '../config';
import { OAuthProviderFactory } from '../factory';
@@ -79,6 +80,15 @@ export abstract class OAuthProvider {
return false;
}
protected fetchOptions(_url: string | URL): SafeFetchOptions {
return {
timeoutMs: 10_000,
maxRedirects: 3,
maxBytes: 1024 * 1024,
allowedHeaders: ['authorization', 'content-type', 'accept'],
};
}
protected async fetchJson<T>(
url: string,
init?: RequestInit,
@@ -87,12 +97,7 @@ export abstract class OAuthProvider {
const response = await safeFetch(
url,
{ ...init, headers: { ...init?.headers, Accept: 'application/json' } },
{
timeoutMs: 10_000,
maxRedirects: 3,
maxBytes: 1024 * 1024,
allowedHeaders: ['authorization', 'content-type', 'accept'],
}
this.fetchOptions(url)
);
const body = await response.text();
@@ -13,6 +13,8 @@ import {
InvalidAuthState,
InvalidOauthResponse,
safeFetch,
type SafeFetchOptions,
SsrfBlockedError,
URLHelper,
} from '../../../base';
import { OAuthOIDCProviderConfig, OAuthProviderName } from '../config';
@@ -65,13 +67,6 @@ type OIDCConfiguration = z.infer<typeof OIDCConfigurationSchema>;
const OIDC_DISCOVERY_INITIAL_RETRY_DELAY = 1000;
const OIDC_DISCOVERY_MAX_RETRY_DELAY = 60_000;
const OIDC_FETCH_OPTIONS = {
timeoutMs: 10_000,
maxRedirects: 3,
maxBytes: 1024 * 1024,
allowedHeaders: ['accept'],
};
@Injectable()
export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
override provider = OAuthProviderName.OIDC;
@@ -96,6 +91,24 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
return true;
}
protected override fetchOptions(rawUrl: string | URL): SafeFetchOptions {
const options = super.fetchOptions(rawUrl);
const config = this.config as OAuthOIDCProviderConfig;
if (!config.allowPrivateNetwork) {
return options;
}
try {
const issuer = new URL(config.issuer);
const target = new URL(rawUrl);
if (target.origin === issuer.origin) {
return { ...options, allowPrivateTargetOrigin: true };
}
} catch {
return options;
}
return options;
}
private get endpoints() {
if (!this.#endpoints) {
throw new Error('OIDC provider is not configured');
@@ -145,10 +158,11 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
}
try {
const discoveryUrl = `${this.normalizeIssuer(config.issuer)}/.well-known/openid-configuration`;
const res = await this.oidcFetch(
`${config.issuer}/.well-known/openid-configuration`,
discoveryUrl,
{ method: 'GET', headers: { Accept: 'application/json' } },
OIDC_FETCH_OPTIONS
this.fetchOptions(discoveryUrl)
);
if (generation !== this.#validationGeneration) {
@@ -176,7 +190,7 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
this.#endpoints = configuration;
this.#jwks = createRemoteJWKSet(new URL(configuration.jwks_uri), {
[customFetch]: (url, init) =>
this.oidcFetch(url, init, OIDC_FETCH_OPTIONS),
this.oidcFetch(url, init, this.fetchOptions(url)),
});
this.#retryScheduler.reset();
super.setup();
@@ -184,7 +198,14 @@ export class OIDCProvider extends OAuthProvider implements OnModuleDestroy {
if (generation !== this.#validationGeneration) {
return;
}
this.logger.error('Failed to validate OIDC configuration', e);
const reason = e instanceof SsrfBlockedError ? e.data?.reason : undefined;
const message =
reason === 'blocked_ip' && !config.allowPrivateNetwork
? 'Failed to validate OIDC configuration: issuer resolves to a private network address; set oauth.providers.oidc.allowPrivateNetwork to true to trust this origin'
: reason
? `Failed to validate OIDC configuration: ${reason}`
: 'Failed to validate OIDC configuration';
this.logger.error(message, e);
this.onValidationFailure(generation);
}
}