feat(server): add OIDC for AFFiNE (#6991)

Co-authored-by: LongYinan <lynweklm@gmail.com>
Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
Tao Chen
2024-05-23 18:35:30 +02:00
committed by GitHub
parent 0c42849bc3
commit 22a8a2663e
11 changed files with 281 additions and 5 deletions

View File

@@ -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',

View File

@@ -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',
// },
// },
// },
// });

View File

@@ -38,7 +38,7 @@ export type ConfigPaths = LeafPaths<
| 'origin'
>,
'',
'.....'
'......'
>;
/**

View File

@@ -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;
}
}
}

View File

@@ -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,
];

View File

@@ -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<typeof OIDCUserInfoSchema>;
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<typeof OIDCConfigurationSchema>;
class OIDCClient {
private static async fetch<T = any>(
url: string,
options: RequestInit,
verifier: z.Schema<T>
): Promise<T> {
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<Tokens> {
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<string, any>,
claimsMap: Record<string, string>
): OIDCUserInfo {
const mappedUser: Partial<OIDCUserInfo> = {};
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<Tokens> {
this.checkOIDCClient(this.client);
return await this.client.token(code);
}
async getUser(token: string): Promise<OAuthAccount> {
this.checkOIDCClient(this.client);
return await this.client.userinfo(token);
}
}

View File

@@ -4,12 +4,31 @@ export interface OAuthProviderConfig {
args?: Record<string, string>;
}
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<OAuthProviderConfigMapping>;
}

View File

@@ -236,6 +236,7 @@ type Mutation {
enum OAuthProviderType {
GitHub
Google
OIDC
}
type PasswordLimitsType {

View File

@@ -23,6 +23,11 @@ const OAuthProviderMap: Record<
[OAuthProviderType.GitHub]: {
icon: <GithubIcon />,
},
[OAuthProviderType.OIDC]: {
// TODO: Add OIDC icon
icon: <GoogleDuotoneIcon />,
},
};
export function OAuth({ redirectUri }: { redirectUri?: string | null }) {

View File

@@ -84,6 +84,7 @@ export enum InvoiceStatus {
export enum OAuthProviderType {
GitHub = 'GitHub',
Google = 'Google',
OIDC = 'OIDC',
}
/** User permission in workspace */