From 22a8a2663ebbda35f5c4b7faf1db141b7efd9e5b Mon Sep 17 00:00:00 2001 From: Tao Chen <42793494+IamTaoChen@users.noreply.github.com> Date: Thu, 23 May 2024 18:35:30 +0200 Subject: [PATCH] feat(server): add OIDC for AFFiNE (#6991) Co-authored-by: LongYinan Co-authored-by: DarkSky --- .github/workflows/build-test.yml | 2 +- .../backend/server/src/config/affine.env.ts | 7 + packages/backend/server/src/config/affine.ts | 14 +- .../server/src/fundamentals/config/def.ts | 2 +- .../server/src/fundamentals/helpers/url.ts | 13 ++ .../src/plugins/oauth/providers/index.ts | 7 +- .../src/plugins/oauth/providers/oidc.ts | 213 ++++++++++++++++++ .../backend/server/src/plugins/oauth/types.ts | 21 +- packages/backend/server/src/schema.gql | 1 + .../core/src/components/affine/auth/oauth.tsx | 5 + packages/frontend/graphql/src/schema.ts | 1 + 11 files changed, 281 insertions(+), 5 deletions(-) create mode 100644 packages/backend/server/src/plugins/oauth/providers/oidc.ts diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 1fb9436a44..f5f8c12844 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -351,7 +351,7 @@ jobs: env: CARGO_TARGET_DIR: '${{ github.workspace }}/target' DATABASE_URL: postgresql://affine:affine@localhost:5432/affine - COPILOT_OPENAI_API_KEY: ${{ secrets.COPILOT_OPENAI_API_KEY }} + COPILOT_OPENAI_API_KEY: 'use_fake_openai_api_key' - name: Upload server test coverage results uses: codecov/codecov-action@v4 diff --git a/packages/backend/server/src/config/affine.env.ts b/packages/backend/server/src/config/affine.env.ts index a49d58590e..f88f3e4efa 100644 --- a/packages/backend/server/src/config/affine.env.ts +++ b/packages/backend/server/src/config/affine.env.ts @@ -11,6 +11,13 @@ AFFiNE.ENV_MAP = { OAUTH_GOOGLE_CLIENT_SECRET: 'plugins.oauth.providers.google.clientSecret', OAUTH_GITHUB_CLIENT_ID: 'plugins.oauth.providers.github.clientId', OAUTH_GITHUB_CLIENT_SECRET: 'plugins.oauth.providers.github.clientSecret', + OAUTH_OIDC_ISSUER: 'plugins.oauth.providers.oidc.issuer', + OAUTH_OIDC_CLIENT_ID: 'plugins.oauth.providers.oidc.clientId', + OAUTH_OIDC_CLIENT_SECRET: 'plugins.oauth.providers.oidc.clientSecret', + OAUTH_OIDC_SCOPE: 'plugins.oauth.providers.oidc.args.scope', + OAUTH_OIDC_CLAIM_MAP_USERNAME: 'plugins.oauth.providers.oidc.args.claim_id', + OAUTH_OIDC_CLAIM_MAP_EMAIL: 'plugins.oauth.providers.oidc.args.claim_email', + OAUTH_OIDC_CLAIM_MAP_NAME: 'plugins.oauth.providers.oidc.args.claim_name', MAILER_HOST: 'mailer.host', MAILER_PORT: ['mailer.port', 'int'], MAILER_USER: 'mailer.auth.user', diff --git a/packages/backend/server/src/config/affine.ts b/packages/backend/server/src/config/affine.ts index d3b21c8d08..107b426dd5 100644 --- a/packages/backend/server/src/config/affine.ts +++ b/packages/backend/server/src/config/affine.ts @@ -131,7 +131,7 @@ AFFiNE.port = 3010; // AFFiNE.storage.storages.blob.provider = 'r2'; // AFFiNE.storage.storages.avatar.provider = 'r2'; // -// /* OAuth Plugin */ +/* OAuth Plugin */ // AFFiNE.plugins.use('oauth', { // providers: { // github: { @@ -152,5 +152,17 @@ AFFiNE.port = 3010; // access_type: 'offline', // }, // }, +// oidc: { +// // OpenID Connect +// issuer: '', +// clientId: '', +// clientSecret: '', +// args: { +// scope: 'openid email profile', +// claim_id: 'preferred_username', +// claim_email: 'email', +// claim_name: 'name', +// }, +// }, // }, // }); diff --git a/packages/backend/server/src/fundamentals/config/def.ts b/packages/backend/server/src/fundamentals/config/def.ts index e3a28c4be8..bbb09b67bc 100644 --- a/packages/backend/server/src/fundamentals/config/def.ts +++ b/packages/backend/server/src/fundamentals/config/def.ts @@ -38,7 +38,7 @@ export type ConfigPaths = LeafPaths< | 'origin' >, '', - '.....' + '......' >; /** diff --git a/packages/backend/server/src/fundamentals/helpers/url.ts b/packages/backend/server/src/fundamentals/helpers/url.ts index ca0931685f..38f8896507 100644 --- a/packages/backend/server/src/fundamentals/helpers/url.ts +++ b/packages/backend/server/src/fundamentals/helpers/url.ts @@ -51,4 +51,17 @@ export class URLHelper { // redirect to home if the url is invalid return res.redirect(this.home); } + + verify(url: string | URL) { + try { + if (typeof url === 'string') { + url = new URL(url); + } + if (!['http:', 'https:'].includes(url.protocol)) return false; + if (!url.hostname) return false; + return true; + } catch (_) { + return false; + } + } } diff --git a/packages/backend/server/src/plugins/oauth/providers/index.ts b/packages/backend/server/src/plugins/oauth/providers/index.ts index 7af95d12d8..0563da7dae 100644 --- a/packages/backend/server/src/plugins/oauth/providers/index.ts +++ b/packages/backend/server/src/plugins/oauth/providers/index.ts @@ -1,4 +1,9 @@ import { GithubOAuthProvider } from './github'; import { GoogleOAuthProvider } from './google'; +import { OIDCProvider } from './oidc'; -export const OAuthProviders = [GoogleOAuthProvider, GithubOAuthProvider]; +export const OAuthProviders = [ + GoogleOAuthProvider, + GithubOAuthProvider, + OIDCProvider, +]; diff --git a/packages/backend/server/src/plugins/oauth/providers/oidc.ts b/packages/backend/server/src/plugins/oauth/providers/oidc.ts new file mode 100644 index 0000000000..4f49fbf20d --- /dev/null +++ b/packages/backend/server/src/plugins/oauth/providers/oidc.ts @@ -0,0 +1,213 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + OnModuleInit, +} from '@nestjs/common'; +import { z } from 'zod'; + +import { Config, URLHelper } from '../../../fundamentals'; +import { AutoRegisteredOAuthProvider } from '../register'; +import { OAuthOIDCProviderConfig, OAuthProviderName, OIDCArgs } from '../types'; +import { OAuthAccount, Tokens } from './def'; + +const OIDCTokenSchema = z.object({ + access_token: z.string(), + expires_in: z.number(), + refresh_token: z.string(), + scope: z.string(), + token_type: z.string(), +}); + +const OIDCUserInfoSchema = z.object({ + id: z.string(), + email: z.string().email(), + name: z.string(), + groups: z.array(z.string()).optional(), +}); + +type OIDCUserInfo = z.infer; + +const OIDCConfigurationSchema = z.object({ + authorization_endpoint: z.string().url(), + token_endpoint: z.string().url(), + userinfo_endpoint: z.string().url(), + end_session_endpoint: z.string().url(), +}); + +type OIDCConfiguration = z.infer; + +class OIDCClient { + private static async fetch( + url: string, + options: RequestInit, + verifier: z.Schema + ): Promise { + const response = await fetch(url, options); + + if (!response.ok) { + if (response.status >= 400 && response.status < 500) { + throw new BadRequestException(`Invalid OIDC configuration`, { + cause: await response.json(), + description: response.statusText, + }); + } else { + throw new InternalServerErrorException(`Failed to configure client`, { + cause: await response.json(), + description: response.statusText, + }); + } + } + return verifier.parse(response.json()); + } + + static async create(config: OAuthOIDCProviderConfig, url: URLHelper) { + const { args, clientId, clientSecret, issuer } = config; + if (!url.verify(issuer)) { + throw new Error('OIDC Issuer is invalid.'); + } + const oidcConfig = await OIDCClient.fetch( + `${issuer}/.well-known/openid-configuration`, + { + method: 'GET', + headers: { Accept: 'application/json' }, + }, + OIDCConfigurationSchema + ); + + return new OIDCClient(clientId, clientSecret, args, oidcConfig, url); + } + + private constructor( + private readonly clientId: string, + private readonly clientSecret: string, + private readonly args: OIDCArgs | undefined, + private readonly config: OIDCConfiguration, + private readonly url: URLHelper + ) {} + + authorize(state: string): string { + const args = Object.assign({}, this.args); + if ('claim_id' in args) delete args.claim_id; + if ('claim_email' in args) delete args.claim_email; + if ('claim_name' in args) delete args.claim_name; + + return `${this.config.authorization_endpoint}?${this.url.stringify({ + client_id: this.clientId, + redirect_uri: this.url.link('/oauth/callback'), + response_type: 'code', + ...args, + scope: this.args?.scope || 'openid profile email', + state, + })}`; + } + + async token(code: string): Promise { + const token = await OIDCClient.fetch( + this.config.token_endpoint, + { + method: 'POST', + body: this.url.stringify({ + code, + client_id: this.clientId, + client_secret: this.clientSecret, + redirect_uri: this.url.link('/oauth/callback'), + grant_type: 'authorization_code', + }), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + OIDCTokenSchema + ); + + return { + accessToken: token.access_token, + refreshToken: token.refresh_token, + expiresAt: new Date(Date.now() + token.expires_in * 1000), + scope: token.scope, + }; + } + + private mapUserInfo( + user: Record, + claimsMap: Record + ): OIDCUserInfo { + const mappedUser: Partial = {}; + for (const [key, value] of Object.entries(claimsMap)) { + if (user[value] !== undefined) { + mappedUser[key as keyof OIDCUserInfo] = user[value]; + } + } + return mappedUser as OIDCUserInfo; + } + + async userinfo(token: string) { + const user = await OIDCClient.fetch( + this.config.userinfo_endpoint, + { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}`, + }, + }, + OIDCUserInfoSchema + ); + + const claimsMap = { + id: this.args?.claim_id || 'preferred_username', + email: this.args?.claim_email || 'email', + name: this.args?.claim_name || 'name', + }; + const userinfo = this.mapUserInfo(user, claimsMap); + return { id: userinfo.id, email: userinfo.email }; + } +} + +@Injectable() +export class OIDCProvider + extends AutoRegisteredOAuthProvider + implements OnModuleInit +{ + override provider = OAuthProviderName.OIDC; + private client: OIDCClient | null = null; + + constructor( + protected readonly AFFiNEConfig: Config, + private readonly url: URLHelper + ) { + super(); + } + + override async onModuleInit() { + const config = this.optionalConfig as OAuthOIDCProviderConfig; + if (config && config.issuer && config.clientId && config.clientSecret) { + this.client = await OIDCClient.create(config, this.url); + super.onModuleInit(); + } + } + + private checkOIDCClient( + client: OIDCClient | null + ): asserts client is OIDCClient { + if (!client) { + throw new Error('OIDC client has not been loaded yet.'); + } + } + + getAuthUrl(state: string): string { + this.checkOIDCClient(this.client); + return this.client.authorize(state); + } + + async getToken(code: string): Promise { + this.checkOIDCClient(this.client); + return await this.client.token(code); + } + async getUser(token: string): Promise { + this.checkOIDCClient(this.client); + return await this.client.userinfo(token); + } +} diff --git a/packages/backend/server/src/plugins/oauth/types.ts b/packages/backend/server/src/plugins/oauth/types.ts index 6d66a264c3..ea7535f5e1 100644 --- a/packages/backend/server/src/plugins/oauth/types.ts +++ b/packages/backend/server/src/plugins/oauth/types.ts @@ -4,12 +4,31 @@ export interface OAuthProviderConfig { args?: Record; } +export type OIDCArgs = { + scope?: string; + claim_id?: string; + claim_email?: string; + claim_name?: string; +}; + +export interface OAuthOIDCProviderConfig extends OAuthProviderConfig { + issuer: string; + args?: OIDCArgs; +} + export enum OAuthProviderName { Google = 'google', GitHub = 'github', + OIDC = 'oidc', } +type OAuthProviderConfigMapping = { + [OAuthProviderName.Google]: OAuthProviderConfig; + [OAuthProviderName.GitHub]: OAuthProviderConfig; + [OAuthProviderName.OIDC]: OAuthOIDCProviderConfig; +}; + export interface OAuthConfig { enabled: boolean; - providers: Partial<{ [key in OAuthProviderName]: OAuthProviderConfig }>; + providers: Partial; } diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 91de8f2597..303befc831 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -236,6 +236,7 @@ type Mutation { enum OAuthProviderType { GitHub Google + OIDC } type PasswordLimitsType { diff --git a/packages/frontend/core/src/components/affine/auth/oauth.tsx b/packages/frontend/core/src/components/affine/auth/oauth.tsx index 848a4b9efc..f8a7e3ac00 100644 --- a/packages/frontend/core/src/components/affine/auth/oauth.tsx +++ b/packages/frontend/core/src/components/affine/auth/oauth.tsx @@ -23,6 +23,11 @@ const OAuthProviderMap: Record< [OAuthProviderType.GitHub]: { icon: , }, + + [OAuthProviderType.OIDC]: { + // TODO: Add OIDC icon + icon: , + }, }; export function OAuth({ redirectUri }: { redirectUri?: string | null }) { diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index b8a12bae5a..dce2789564 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -84,6 +84,7 @@ export enum InvoiceStatus { export enum OAuthProviderType { GitHub = 'GitHub', Google = 'Google', + OIDC = 'OIDC', } /** User permission in workspace */