mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-22 12:36:24 +08:00
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:
@@ -6,7 +6,12 @@ import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { AppModule } from '../../app.module';
|
||||
import { ConfigFactory, InvalidOauthResponse, URLHelper } from '../../base';
|
||||
import {
|
||||
ConfigFactory,
|
||||
InvalidOauthResponse,
|
||||
type SafeFetchOptions,
|
||||
URLHelper,
|
||||
} from '../../base';
|
||||
import { SessionCache } from '../../base/cache';
|
||||
import { ConfigModule } from '../../base/config';
|
||||
import { CurrentUser } from '../../core/auth';
|
||||
@@ -531,6 +536,7 @@ function createOidcRegistrationHarness(config?: {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
issuer?: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
}) {
|
||||
const server = {
|
||||
enableFeature: Sinon.spy(),
|
||||
@@ -551,6 +557,7 @@ function createOidcRegistrationHarness(config?: {
|
||||
clientId: config?.clientId ?? 'oidc-client-id',
|
||||
clientSecret: config?.clientSecret ?? 'oidc-client-secret',
|
||||
issuer: config?.issuer ?? 'https://issuer.affine.dev',
|
||||
allowPrivateNetwork: config?.allowPrivateNetwork ?? false,
|
||||
args: {},
|
||||
},
|
||||
},
|
||||
@@ -565,6 +572,12 @@ function createOidcRegistrationHarness(config?: {
|
||||
provider,
|
||||
factory,
|
||||
server,
|
||||
fetchOptions: (url: string) =>
|
||||
(
|
||||
provider as unknown as {
|
||||
fetchOptions(url: string): SafeFetchOptions;
|
||||
}
|
||||
).fetchOptions(url),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -940,7 +953,16 @@ test('oidc should not fall back to default email claim when custom claim is conf
|
||||
});
|
||||
|
||||
test('oidc discovery should remove oauth feature on failure and restore it after backoff retry succeeds', async t => {
|
||||
const { provider, factory, server } = createOidcRegistrationHarness();
|
||||
const issuer = 'https://auth.internal/application/o/affine/';
|
||||
const { fetchOptions: defaultFetchOptions } = createOidcRegistrationHarness({
|
||||
issuer,
|
||||
});
|
||||
t.falsy(defaultFetchOptions(issuer).allowPrivateTargetOrigin);
|
||||
|
||||
const { provider, factory, server } = createOidcRegistrationHarness({
|
||||
issuer,
|
||||
allowPrivateNetwork: true,
|
||||
});
|
||||
const fetchStub = Sinon.stub(provider as any, 'oidcFetch');
|
||||
const scheduledRetries: Array<() => void> = [];
|
||||
const retryDelays: number[] = [];
|
||||
@@ -967,11 +989,11 @@ test('oidc discovery should remove oauth feature on failure and restore it after
|
||||
.resolves(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
authorization_endpoint: 'https://issuer.affine.dev/auth',
|
||||
token_endpoint: 'https://issuer.affine.dev/token',
|
||||
userinfo_endpoint: 'https://issuer.affine.dev/userinfo',
|
||||
issuer: 'https://issuer.affine.dev',
|
||||
jwks_uri: 'https://issuer.affine.dev/jwks',
|
||||
authorization_endpoint: `${issuer}authorize`,
|
||||
token_endpoint: `${issuer}token`,
|
||||
userinfo_endpoint: `${issuer}userinfo`,
|
||||
issuer,
|
||||
jwks_uri: `${issuer}jwks`,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
@@ -986,6 +1008,11 @@ test('oidc discovery should remove oauth feature on failure and restore it after
|
||||
t.deepEqual(factory.providers, []);
|
||||
t.true(server.disableFeature.calledWith(ServerFeature.OAuth));
|
||||
t.is(fetchStub.callCount, 1);
|
||||
t.is(
|
||||
fetchStub.firstCall.args[0],
|
||||
`${issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`
|
||||
);
|
||||
t.true(fetchStub.firstCall.args[2].allowPrivateTargetOrigin);
|
||||
t.deepEqual(retryDelays, [1000]);
|
||||
|
||||
const firstRetry = scheduledRetries.shift();
|
||||
|
||||
@@ -280,12 +280,24 @@ export const USER_FRIENDLY_ERRORS = {
|
||||
args: { reason: 'string' },
|
||||
message: ({ reason }) => {
|
||||
switch (reason) {
|
||||
case 'invalid_url':
|
||||
return 'Invalid URL';
|
||||
case 'disallowed_protocol':
|
||||
return 'URL protocol is not allowed';
|
||||
case 'url_has_credentials':
|
||||
return 'URL must not contain credentials';
|
||||
case 'blocked_hostname':
|
||||
return 'URL hostname is not allowed';
|
||||
case 'host_not_allowed':
|
||||
return 'URL hostname is outside the allowed hosts';
|
||||
case 'unresolvable_hostname':
|
||||
return 'Failed to resolve hostname';
|
||||
case 'blocked_ip':
|
||||
return 'URL resolves to a private or reserved IP address';
|
||||
case 'too_many_redirects':
|
||||
return 'Too many redirects';
|
||||
default:
|
||||
return 'Invalid URL';
|
||||
return `URL blocked by SSRF protection: ${reason}`;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user