mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-23 13:29:02 +08:00
feat(server): improve oidc compatibility (#14686)
fix #13938 fix #14683 fix #14532 #### PR Dependency Tree * **PR #14686** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Flexible OIDC claim mapping for email/name, automatic OIDC discovery retry with exponential backoff, and explicit OAuth flow modes (popup vs redirect) propagated through the auth flow. * **Bug Fixes** * Stricter OIDC email validation, clearer error messages listing attempted claim candidates, and improved callback redirect handling for various flow scenarios. * **Tests** * Added unit tests covering OIDC behaviors, backoff scheduler/promise utilities, and frontend OAuth flow parsing/redirect logic. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -6,13 +6,16 @@ import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { AppModule } from '../../app.module';
|
||||
import { ConfigFactory, URLHelper } from '../../base';
|
||||
import { ConfigFactory, InvalidOauthResponse, URLHelper } from '../../base';
|
||||
import { ConfigModule } from '../../base/config';
|
||||
import { CurrentUser } from '../../core/auth';
|
||||
import { AuthService } from '../../core/auth/service';
|
||||
import { ServerFeature } from '../../core/config/types';
|
||||
import { Models } from '../../models';
|
||||
import { OAuthProviderName } from '../../plugins/oauth/config';
|
||||
import { OAuthProviderFactory } from '../../plugins/oauth/factory';
|
||||
import { GoogleOAuthProvider } from '../../plugins/oauth/providers/google';
|
||||
import { OIDCProvider } from '../../plugins/oauth/providers/oidc';
|
||||
import { OAuthService } from '../../plugins/oauth/service';
|
||||
import { createTestingApp, currentUser, TestingApp } from '../utils';
|
||||
|
||||
@@ -35,6 +38,12 @@ test.before(async t => {
|
||||
clientId: 'google-client-id',
|
||||
clientSecret: 'google-client-secret',
|
||||
},
|
||||
oidc: {
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
issuer: '',
|
||||
args: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
@@ -432,6 +441,87 @@ function mockOAuthProvider(
|
||||
return clientNonce;
|
||||
}
|
||||
|
||||
function mockOidcProvider(
|
||||
provider: OIDCProvider,
|
||||
{
|
||||
args = {},
|
||||
idTokenClaims,
|
||||
userinfo,
|
||||
}: {
|
||||
args?: Record<string, string>;
|
||||
idTokenClaims: Record<string, unknown>;
|
||||
userinfo: Record<string, unknown>;
|
||||
}
|
||||
) {
|
||||
Sinon.stub(provider, 'config').get(() => ({
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
issuer: '',
|
||||
args,
|
||||
}));
|
||||
Sinon.stub(
|
||||
provider as unknown as { endpoints: { userinfo_endpoint: string } },
|
||||
'endpoints'
|
||||
).get(() => ({
|
||||
userinfo_endpoint: 'https://oidc.affine.dev/userinfo',
|
||||
}));
|
||||
Sinon.stub(
|
||||
provider as unknown as { verifyIdToken: () => unknown },
|
||||
'verifyIdToken'
|
||||
).resolves(idTokenClaims);
|
||||
Sinon.stub(
|
||||
provider as unknown as { fetchJson: () => unknown },
|
||||
'fetchJson'
|
||||
).resolves(userinfo);
|
||||
}
|
||||
|
||||
function createOidcRegistrationHarness(config?: {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
issuer?: string;
|
||||
}) {
|
||||
const server = {
|
||||
enableFeature: Sinon.spy(),
|
||||
disableFeature: Sinon.spy(),
|
||||
};
|
||||
const factory = new OAuthProviderFactory(server as any);
|
||||
const affineConfig = {
|
||||
server: {
|
||||
externalUrl: 'https://affine.example',
|
||||
host: 'localhost',
|
||||
path: '',
|
||||
https: true,
|
||||
hosts: [],
|
||||
},
|
||||
oauth: {
|
||||
providers: {
|
||||
oidc: {
|
||||
clientId: config?.clientId ?? 'oidc-client-id',
|
||||
clientSecret: config?.clientSecret ?? 'oidc-client-secret',
|
||||
issuer: config?.issuer ?? 'https://issuer.affine.dev',
|
||||
args: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const provider = new OIDCProvider(new URLHelper(affineConfig as any));
|
||||
|
||||
(provider as any).factory = factory;
|
||||
(provider as any).AFFiNEConfig = affineConfig;
|
||||
|
||||
return {
|
||||
provider,
|
||||
factory,
|
||||
server,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushAsyncWork(iterations = 5) {
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
test('should be able to sign up with oauth', async t => {
|
||||
const { app, db } = t.context;
|
||||
|
||||
@@ -554,3 +644,209 @@ test('should be able to fullfil user with oauth sign in', async t => {
|
||||
t.truthy(account);
|
||||
t.is(account!.user.id, u3.id);
|
||||
});
|
||||
|
||||
test('oidc should accept email from id token when userinfo email is missing', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const provider = app.get(OIDCProvider);
|
||||
mockOidcProvider(provider, {
|
||||
idTokenClaims: {
|
||||
sub: 'oidc-user',
|
||||
email: 'oidc-id-token@affine.pro',
|
||||
name: 'OIDC User',
|
||||
},
|
||||
userinfo: {
|
||||
sub: 'oidc-user',
|
||||
name: 'OIDC User',
|
||||
},
|
||||
});
|
||||
|
||||
const user = await provider.getUser(
|
||||
{ accessToken: 'token', idToken: 'id-token' },
|
||||
{ token: 'nonce', provider: OAuthProviderName.OIDC }
|
||||
);
|
||||
|
||||
t.is(user.id, 'oidc-user');
|
||||
t.is(user.email, 'oidc-id-token@affine.pro');
|
||||
t.is(user.name, 'OIDC User');
|
||||
});
|
||||
|
||||
test('oidc should resolve custom email claim from userinfo', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const provider = app.get(OIDCProvider);
|
||||
mockOidcProvider(provider, {
|
||||
args: { claim_email: 'mail', claim_name: 'display_name' },
|
||||
idTokenClaims: {
|
||||
sub: 'oidc-user',
|
||||
},
|
||||
userinfo: {
|
||||
sub: 'oidc-user',
|
||||
mail: 'oidc-userinfo@affine.pro',
|
||||
display_name: 'OIDC Custom',
|
||||
},
|
||||
});
|
||||
|
||||
const user = await provider.getUser(
|
||||
{ accessToken: 'token', idToken: 'id-token' },
|
||||
{ token: 'nonce', provider: OAuthProviderName.OIDC }
|
||||
);
|
||||
|
||||
t.is(user.id, 'oidc-user');
|
||||
t.is(user.email, 'oidc-userinfo@affine.pro');
|
||||
t.is(user.name, 'OIDC Custom');
|
||||
});
|
||||
|
||||
test('oidc should resolve custom email claim from id token', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const provider = app.get(OIDCProvider);
|
||||
mockOidcProvider(provider, {
|
||||
args: { claim_email: 'mail', claim_email_verified: 'mail_verified' },
|
||||
idTokenClaims: {
|
||||
sub: 'oidc-user',
|
||||
mail: 'oidc-custom-id-token@affine.pro',
|
||||
mail_verified: 'true',
|
||||
},
|
||||
userinfo: {
|
||||
sub: 'oidc-user',
|
||||
},
|
||||
});
|
||||
|
||||
const user = await provider.getUser(
|
||||
{ accessToken: 'token', idToken: 'id-token' },
|
||||
{ token: 'nonce', provider: OAuthProviderName.OIDC }
|
||||
);
|
||||
|
||||
t.is(user.id, 'oidc-user');
|
||||
t.is(user.email, 'oidc-custom-id-token@affine.pro');
|
||||
});
|
||||
|
||||
test('oidc should reject responses without a usable email claim', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const provider = app.get(OIDCProvider);
|
||||
mockOidcProvider(provider, {
|
||||
args: { claim_email: 'mail' },
|
||||
idTokenClaims: {
|
||||
sub: 'oidc-user',
|
||||
mail: 'not-an-email',
|
||||
},
|
||||
userinfo: {
|
||||
sub: 'oidc-user',
|
||||
mail: 'still-not-an-email',
|
||||
},
|
||||
});
|
||||
|
||||
const error = await t.throwsAsync(
|
||||
provider.getUser(
|
||||
{ accessToken: 'token', idToken: 'id-token' },
|
||||
{ token: 'nonce', provider: OAuthProviderName.OIDC }
|
||||
)
|
||||
);
|
||||
|
||||
t.true(error instanceof InvalidOauthResponse);
|
||||
t.true(
|
||||
error.message.includes(
|
||||
'Missing valid email claim in OIDC response. Tried userinfo and ID token claims: "mail"'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('oidc should not fall back to default email claim when custom claim is configured', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const provider = app.get(OIDCProvider);
|
||||
mockOidcProvider(provider, {
|
||||
args: { claim_email: 'mail' },
|
||||
idTokenClaims: {
|
||||
sub: 'oidc-user',
|
||||
email: 'fallback@affine.pro',
|
||||
},
|
||||
userinfo: {
|
||||
sub: 'oidc-user',
|
||||
email: 'userinfo-fallback@affine.pro',
|
||||
},
|
||||
});
|
||||
|
||||
const error = await t.throwsAsync(
|
||||
provider.getUser(
|
||||
{ accessToken: 'token', idToken: 'id-token' },
|
||||
{ token: 'nonce', provider: OAuthProviderName.OIDC }
|
||||
)
|
||||
);
|
||||
|
||||
t.true(error instanceof InvalidOauthResponse);
|
||||
t.true(
|
||||
error.message.includes(
|
||||
'Missing valid email claim in OIDC response. Tried userinfo and ID token claims: "mail"'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('oidc discovery should remove oauth feature on failure and restore it after backoff retry succeeds', async t => {
|
||||
const { provider, factory, server } = createOidcRegistrationHarness();
|
||||
const fetchStub = Sinon.stub(globalThis, 'fetch');
|
||||
const scheduledRetries: Array<() => void> = [];
|
||||
const retryDelays: number[] = [];
|
||||
const setTimeoutStub = Sinon.stub(globalThis, 'setTimeout').callsFake(((
|
||||
callback: Parameters<typeof setTimeout>[0],
|
||||
delay?: number
|
||||
) => {
|
||||
retryDelays.push(Number(delay));
|
||||
scheduledRetries.push(callback as () => void);
|
||||
return Symbol('timeout') as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as typeof setTimeout);
|
||||
t.teardown(() => {
|
||||
provider.onModuleDestroy();
|
||||
fetchStub.restore();
|
||||
setTimeoutStub.restore();
|
||||
});
|
||||
|
||||
fetchStub
|
||||
.onFirstCall()
|
||||
.rejects(new Error('temporary discovery failure'))
|
||||
.onSecondCall()
|
||||
.rejects(new Error('temporary discovery failure'))
|
||||
.onThirdCall()
|
||||
.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',
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
(provider as any).setup();
|
||||
|
||||
await flushAsyncWork();
|
||||
t.deepEqual(factory.providers, []);
|
||||
t.true(server.disableFeature.calledWith(ServerFeature.OAuth));
|
||||
t.is(fetchStub.callCount, 1);
|
||||
t.deepEqual(retryDelays, [1000]);
|
||||
|
||||
const firstRetry = scheduledRetries.shift();
|
||||
t.truthy(firstRetry);
|
||||
firstRetry!();
|
||||
await flushAsyncWork();
|
||||
t.is(fetchStub.callCount, 2);
|
||||
t.deepEqual(factory.providers, []);
|
||||
t.deepEqual(retryDelays, [1000, 2000]);
|
||||
|
||||
const secondRetry = scheduledRetries.shift();
|
||||
t.truthy(secondRetry);
|
||||
secondRetry!();
|
||||
await flushAsyncWork();
|
||||
t.is(fetchStub.callCount, 3);
|
||||
t.deepEqual(factory.providers, [OAuthProviderName.OIDC]);
|
||||
t.true(server.enableFeature.calledWith(ServerFeature.OAuth));
|
||||
t.is(scheduledRetries.length, 0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user