diff --git a/packages/wabe/dev/index.ts b/packages/wabe/dev/index.ts index f1e4053b..b250cc1c 100644 --- a/packages/wabe/dev/index.ts +++ b/packages/wabe/dev/index.ts @@ -25,8 +25,8 @@ const run = async () => { cookieSession: true, }, roles: ['Admin', 'Client'], - successRedirectPath: 'http://localhost:3000/auth/oauth?provider=google', - failureRedirectPath: 'http://localhost:3000/auth/oauth?provider=google', + successRedirectPath: 'http://shipmysaas.com', + failureRedirectPath: 'https://shipmysaas.com', customAuthenticationMethods: [ { name: 'otp', diff --git a/packages/wabe/generated/schema.graphql b/packages/wabe/generated/schema.graphql index 381c7496..e13644a4 100644 --- a/packages/wabe/generated/schema.graphql +++ b/packages/wabe/generated/schema.graphql @@ -4,6 +4,7 @@ enum RoleEnum { } enum AuthenticationProvider { + github google emailPassword phonePassword @@ -58,6 +59,7 @@ type UserAuthentication { phonePassword: UserAuthenticationPhonePassword emailPassword: UserAuthenticationEmailPassword google: UserAuthenticationGoogle + github: UserAuthenticationGithub } type UserAuthenticationPhonePassword { @@ -76,7 +78,12 @@ type UserAuthenticationEmailPassword { type UserAuthenticationGoogle { email: Email! verifiedEmail: Boolean! - idToken: String! +} + +type UserAuthenticationGithub { + email: Email! + avatarUrl: String! + username: String! } type _SessionConnection { @@ -126,6 +133,7 @@ input UserAuthenticationInput { phonePassword: UserAuthenticationPhonePasswordInput emailPassword: UserAuthenticationEmailPasswordInput google: UserAuthenticationGoogleInput + github: UserAuthenticationGithubInput } input UserAuthenticationPhonePasswordInput { @@ -141,7 +149,12 @@ input UserAuthenticationEmailPasswordInput { input UserAuthenticationGoogleInput { email: Email! verifiedEmail: Boolean! - idToken: String! +} + +input UserAuthenticationGithubInput { + email: Email! + avatarUrl: String! + username: String! } """Input to link an object to a pointer User""" @@ -189,6 +202,7 @@ input UserAuthenticationCreateFieldsInput { phonePassword: UserAuthenticationPhonePasswordCreateFieldsInput emailPassword: UserAuthenticationEmailPasswordCreateFieldsInput google: UserAuthenticationGoogleCreateFieldsInput + github: UserAuthenticationGithubCreateFieldsInput } input UserAuthenticationPhonePasswordCreateFieldsInput { @@ -204,7 +218,12 @@ input UserAuthenticationEmailPasswordCreateFieldsInput { input UserAuthenticationGoogleCreateFieldsInput { email: Email verifiedEmail: Boolean - idToken: String +} + +input UserAuthenticationGithubCreateFieldsInput { + email: Email + avatarUrl: String + username: String } """Input to add a relation to the class User""" @@ -737,6 +756,7 @@ input UserAuthenticationWhereInput { phonePassword: UserAuthenticationPhonePasswordWhereInput emailPassword: UserAuthenticationEmailPasswordWhereInput google: UserAuthenticationGoogleWhereInput + github: UserAuthenticationGithubWhereInput OR: [UserAuthenticationWhereInput] AND: [UserAuthenticationWhereInput] } @@ -765,11 +785,18 @@ input UserAuthenticationEmailPasswordWhereInput { input UserAuthenticationGoogleWhereInput { email: EmailWhereInput verifiedEmail: BooleanWhereInput - idToken: StringWhereInput OR: [UserAuthenticationGoogleWhereInput] AND: [UserAuthenticationGoogleWhereInput] } +input UserAuthenticationGithubWhereInput { + email: EmailWhereInput + avatarUrl: StringWhereInput + username: StringWhereInput + OR: [UserAuthenticationGithubWhereInput] + AND: [UserAuthenticationGithubWhereInput] +} + input AnyWhereInput { equalTo: Any notEqualTo: Any @@ -1187,6 +1214,7 @@ input UserAuthenticationUpdateFieldsInput { phonePassword: UserAuthenticationPhonePasswordUpdateFieldsInput emailPassword: UserAuthenticationEmailPasswordUpdateFieldsInput google: UserAuthenticationGoogleUpdateFieldsInput + github: UserAuthenticationGithubUpdateFieldsInput } input UserAuthenticationPhonePasswordUpdateFieldsInput { @@ -1202,7 +1230,12 @@ input UserAuthenticationEmailPasswordUpdateFieldsInput { input UserAuthenticationGoogleUpdateFieldsInput { email: Email verifiedEmail: Boolean - idToken: String +} + +input UserAuthenticationGithubUpdateFieldsInput { + email: Email + avatarUrl: String + username: String } input UpdateUsersInput { @@ -1579,6 +1612,7 @@ input SignInWithAuthenticationInput { phonePassword: SignInWithAuthenticationPhonePasswordInput emailPassword: SignInWithAuthenticationEmailPasswordInput google: SignInWithAuthenticationGoogleInput + github: SignInWithAuthenticationGithubInput otp: SignInWithAuthenticationOtpInput secondaryFactor: SecondaryFactor } @@ -1598,6 +1632,11 @@ input SignInWithAuthenticationGoogleInput { codeVerifier: String! } +input SignInWithAuthenticationGithubInput { + authorizationCode: String! + codeVerifier: String! +} + input SignInWithAuthenticationOtpInput { code: String } @@ -1616,6 +1655,7 @@ input SignUpWithAuthenticationInput { phonePassword: SignUpWithAuthenticationPhonePasswordInput emailPassword: SignUpWithAuthenticationEmailPasswordInput google: SignUpWithAuthenticationGoogleInput + github: SignUpWithAuthenticationGithubInput otp: SignUpWithAuthenticationOtpInput secondaryFactor: SecondaryFactor } @@ -1635,6 +1675,11 @@ input SignUpWithAuthenticationGoogleInput { codeVerifier: String! } +input SignUpWithAuthenticationGithubInput { + authorizationCode: String! + codeVerifier: String! +} + input SignUpWithAuthenticationOtpInput { code: String } diff --git a/packages/wabe/generated/wabe.ts b/packages/wabe/generated/wabe.ts index 2e1865ff..8c1e429c 100644 --- a/packages/wabe/generated/wabe.ts +++ b/packages/wabe/generated/wabe.ts @@ -19,6 +19,7 @@ export enum RoleEnum { } export enum AuthenticationProvider { + github = "github", google = "google", emailPassword = "emailPassword", phonePassword = "phonePassword", @@ -66,6 +67,7 @@ export type UserAuthentication = { phonePassword?: UserAuthenticationPhonePassword; emailPassword?: UserAuthenticationEmailPassword; google?: UserAuthenticationGoogle; + github?: UserAuthenticationGithub; }; export type UserAuthenticationPhonePassword = { @@ -81,7 +83,12 @@ export type UserAuthenticationEmailPassword = { export type UserAuthenticationGoogle = { email: Scalars['Email']['output']; verifiedEmail: Scalars['Boolean']['output']; - idToken: Scalars['String']['output']; +}; + +export type UserAuthenticationGithub = { + email: Scalars['Email']['output']; + avatarUrl: Scalars['String']['output']; + username: Scalars['String']['output']; }; export type _SessionConnection = { @@ -130,6 +137,7 @@ export type UserAuthenticationInput = { phonePassword?: UserAuthenticationPhonePasswordInput; emailPassword?: UserAuthenticationEmailPasswordInput; google?: UserAuthenticationGoogleInput; + github?: UserAuthenticationGithubInput; }; export type UserAuthenticationPhonePasswordInput = { @@ -145,7 +153,12 @@ export type UserAuthenticationEmailPasswordInput = { export type UserAuthenticationGoogleInput = { email: Scalars['Email']['input']; verifiedEmail: Scalars['Boolean']['input']; - idToken: Scalars['String']['input']; +}; + +export type UserAuthenticationGithubInput = { + email: Scalars['Email']['input']; + avatarUrl: Scalars['String']['input']; + username: Scalars['String']['input']; }; export type UserPointerInput = { @@ -191,6 +204,7 @@ export type UserAuthenticationCreateFieldsInput = { phonePassword?: UserAuthenticationPhonePasswordCreateFieldsInput; emailPassword?: UserAuthenticationEmailPasswordCreateFieldsInput; google?: UserAuthenticationGoogleCreateFieldsInput; + github?: UserAuthenticationGithubCreateFieldsInput; }; export type UserAuthenticationPhonePasswordCreateFieldsInput = { @@ -206,7 +220,12 @@ export type UserAuthenticationEmailPasswordCreateFieldsInput = { export type UserAuthenticationGoogleCreateFieldsInput = { email?: Scalars['Email']['input']; verifiedEmail?: Scalars['Boolean']['input']; - idToken?: Scalars['String']['input']; +}; + +export type UserAuthenticationGithubCreateFieldsInput = { + email?: Scalars['Email']['input']; + avatarUrl?: Scalars['String']['input']; + username?: Scalars['String']['input']; }; export type UserRelationInput = { @@ -777,6 +796,7 @@ export type UserAuthenticationWhereInput = { phonePassword?: UserAuthenticationPhonePasswordWhereInput; emailPassword?: UserAuthenticationEmailPasswordWhereInput; google?: UserAuthenticationGoogleWhereInput; + github?: UserAuthenticationGithubWhereInput; OR?: UserAuthenticationWhereInput[]; AND?: UserAuthenticationWhereInput[]; }; @@ -805,11 +825,18 @@ export type UserAuthenticationEmailPasswordWhereInput = { export type UserAuthenticationGoogleWhereInput = { email?: EmailWhereInput; verifiedEmail?: BooleanWhereInput; - idToken?: StringWhereInput; OR?: UserAuthenticationGoogleWhereInput[]; AND?: UserAuthenticationGoogleWhereInput[]; }; +export type UserAuthenticationGithubWhereInput = { + email?: EmailWhereInput; + avatarUrl?: StringWhereInput; + username?: StringWhereInput; + OR?: UserAuthenticationGithubWhereInput[]; + AND?: UserAuthenticationGithubWhereInput[]; +}; + export type AnyWhereInput = { equalTo?: Scalars['Any']['input']; notEqualTo?: Scalars['Any']['input']; @@ -1364,6 +1391,7 @@ export type UserAuthenticationUpdateFieldsInput = { phonePassword?: UserAuthenticationPhonePasswordUpdateFieldsInput; emailPassword?: UserAuthenticationEmailPasswordUpdateFieldsInput; google?: UserAuthenticationGoogleUpdateFieldsInput; + github?: UserAuthenticationGithubUpdateFieldsInput; }; export type UserAuthenticationPhonePasswordUpdateFieldsInput = { @@ -1379,7 +1407,12 @@ export type UserAuthenticationEmailPasswordUpdateFieldsInput = { export type UserAuthenticationGoogleUpdateFieldsInput = { email?: Scalars['Email']['input']; verifiedEmail?: Scalars['Boolean']['input']; - idToken?: Scalars['String']['input']; +}; + +export type UserAuthenticationGithubUpdateFieldsInput = { + email?: Scalars['Email']['input']; + avatarUrl?: Scalars['String']['input']; + username?: Scalars['String']['input']; }; export type UpdateUsersInput = { @@ -1756,6 +1789,7 @@ export type SignInWithAuthenticationInput = { phonePassword?: SignInWithAuthenticationPhonePasswordInput; emailPassword?: SignInWithAuthenticationEmailPasswordInput; google?: SignInWithAuthenticationGoogleInput; + github?: SignInWithAuthenticationGithubInput; otp?: SignInWithAuthenticationOtpInput; secondaryFactor?: SecondaryFactor; }; @@ -1775,6 +1809,11 @@ export type SignInWithAuthenticationGoogleInput = { codeVerifier: Scalars['String']['input']; }; +export type SignInWithAuthenticationGithubInput = { + authorizationCode: Scalars['String']['input']; + codeVerifier: Scalars['String']['input']; +}; + export type SignInWithAuthenticationOtpInput = { code?: Scalars['String']['input']; }; @@ -1793,6 +1832,7 @@ export type SignUpWithAuthenticationInput = { phonePassword?: SignUpWithAuthenticationPhonePasswordInput; emailPassword?: SignUpWithAuthenticationEmailPasswordInput; google?: SignUpWithAuthenticationGoogleInput; + github?: SignUpWithAuthenticationGithubInput; otp?: SignUpWithAuthenticationOtpInput; secondaryFactor?: SecondaryFactor; }; @@ -1812,6 +1852,11 @@ export type SignUpWithAuthenticationGoogleInput = { codeVerifier: Scalars['String']['input']; }; +export type SignUpWithAuthenticationGithubInput = { + authorizationCode: Scalars['String']['input']; + codeVerifier: Scalars['String']['input']; +}; + export type SignUpWithAuthenticationOtpInput = { code?: Scalars['String']['input']; }; diff --git a/packages/wabe/src/authentication/defaultAuthentication.ts b/packages/wabe/src/authentication/defaultAuthentication.ts index d3723552..74b1fe89 100644 --- a/packages/wabe/src/authentication/defaultAuthentication.ts +++ b/packages/wabe/src/authentication/defaultAuthentication.ts @@ -1,5 +1,6 @@ import type { WabeTypes } from '..' import type { CustomAuthenticationMethods } from './interface' +import { GitHub } from './providers' import { Google } from './providers' import { EmailPassword } from './providers/EmailPassword' import { PhonePassword } from './providers/PhonePassword' @@ -76,13 +77,39 @@ export const defaultAuthenticationMethods = < type: 'Boolean', required: true, }, - idToken: { + }, + // There is no signUp method for Google provider + // @ts-expect-error + provider: new Google(), + }, + { + name: 'github', + input: { + authorizationCode: { + type: 'String', + required: true, + }, + codeVerifier: { + type: 'String', + required: true, + }, + }, + dataToStore: { + email: { + type: 'Email', + required: true, + }, + avatarUrl: { + type: 'String', + required: true, + }, + username: { type: 'String', required: true, }, }, // There is no signUp method for Google provider // @ts-expect-error - provider: new Google(), + provider: new GitHub(), }, ] diff --git a/packages/wabe/src/authentication/interface.ts b/packages/wabe/src/authentication/interface.ts index c13a3179..738f96dd 100644 --- a/packages/wabe/src/authentication/interface.ts +++ b/packages/wabe/src/authentication/interface.ts @@ -5,7 +5,7 @@ import type { WabeTypes } from '../server' export enum ProviderEnum { google = 'google', - x = 'x', + github = 'github', } export interface ProviderConfig { @@ -99,6 +99,7 @@ export interface Provider { } export enum AuthenticationProvider { + GitHub = 'github', Google = 'google', EmailPassword = 'emailPassword', PhonePassword = 'phonePassword', diff --git a/packages/wabe/src/authentication/oauth/GitHub.test.ts b/packages/wabe/src/authentication/oauth/GitHub.test.ts new file mode 100644 index 00000000..273163b0 --- /dev/null +++ b/packages/wabe/src/authentication/oauth/GitHub.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, spyOn } from 'bun:test' +import { GitHub } from './GitHub' +import { OAuth2Client } from './Oauth2Client' + +describe('GitHub oauth', () => { + const config = { + port: 3000, + authentication: { + backDomain: 'api.shipmysaas.com', + providers: { + github: { + clientId: 'clientId', + clientSecret: 'clientSecret', + }, + }, + }, + } as any + + const githubOauth = new GitHub(config) + + it('should create authorization url', () => { + const spyOauth2ClientCreateAuthorizationUrl = spyOn( + OAuth2Client.prototype, + 'createAuthorizationURL', + ).mockReturnValue(new URL('https://url') as never) + + const authorizationUrl = githubOauth.createAuthorizationURL( + 'state', + 'codeVerifier', + ) + + expect(authorizationUrl.toString()).toBe( + 'https://url/?access_type=offline&prompt=select_account', + ) + expect(spyOauth2ClientCreateAuthorizationUrl).toHaveBeenCalledTimes(1) + expect(spyOauth2ClientCreateAuthorizationUrl).toHaveBeenCalledWith({ + state: 'state', + codeVerifier: 'codeVerifier', + scopes: ['read:user', 'user:email'], + }) + + spyOauth2ClientCreateAuthorizationUrl.mockRestore() + }) + + it('should validate authorization code', async () => { + const spyOauth2ClientValidateAuthorizationCode = spyOn( + OAuth2Client.prototype, + 'validateAuthorizationCode', + ).mockResolvedValue({ + access_token: 'access_token', + refresh_token: 'refresh_token', + expires_in: 3600, + }) + + const res = await githubOauth.validateAuthorizationCode( + 'code', + 'codeVerifier', + ) + + expect(spyOauth2ClientValidateAuthorizationCode).toHaveBeenCalledTimes(1) + expect(spyOauth2ClientValidateAuthorizationCode).toHaveBeenCalledWith( + 'code', + { + authenticateWith: 'request_body', + credentials: 'clientSecret', + codeVerifier: 'codeVerifier', + }, + ) + + expect(res.accessTokenExpiresAt?.getTime()).toBeGreaterThanOrEqual( + Date.now() + 3600 * 1000, + ) + + spyOauth2ClientValidateAuthorizationCode.mockRestore() + }) + + it('should refresh access token', async () => { + const spyOauth2ClientRefreshAccessToken = spyOn( + OAuth2Client.prototype, + 'refreshAccessToken', + ).mockResolvedValue({ + access_token: 'access_token', + expires_in: 3600, + }) + + const res = await githubOauth.refreshAccessToken('refresh_token') + + expect(spyOauth2ClientRefreshAccessToken).toHaveBeenCalledTimes(1) + expect(spyOauth2ClientRefreshAccessToken).toHaveBeenCalledWith( + 'refresh_token', + { + authenticateWith: 'request_body', + credentials: 'clientSecret', + }, + ) + + expect(res.accessToken).toBe('access_token') + expect(res.accessTokenExpiresAt?.getTime()).toBeGreaterThanOrEqual( + Date.now() + 3600 * 1000, + ) + + spyOauth2ClientRefreshAccessToken.mockRestore() + }) +}) diff --git a/packages/wabe/src/authentication/oauth/GitHub.ts b/packages/wabe/src/authentication/oauth/GitHub.ts new file mode 100644 index 00000000..f1f968b4 --- /dev/null +++ b/packages/wabe/src/authentication/oauth/GitHub.ts @@ -0,0 +1,134 @@ +import { OAuth2Client } from '.' +import type { WabeConfig } from '../../server' +import type { OAuth2ProviderWithPKCE, Tokens } from './utils' + +const authorizeEndpoint = 'https://github.com/login/oauth/authorize' +const tokenEndpoint = 'https://github.com/login/oauth/access_token' + +interface AuthorizationCodeResponseBody { + access_token: string + refresh_token?: string + expires_in: number + id_token: string +} + +interface RefreshTokenResponseBody { + access_token: string + expires_in: number +} + +export class GitHub implements OAuth2ProviderWithPKCE { + private client: OAuth2Client + private clientSecret: string + + constructor(config: WabeConfig) { + const githubConfig = config.authentication?.providers?.github + + if (!githubConfig) throw new Error('GitHub config not found') + + const baseUrl = `http${config.isProduction ? 's' : ''}://${config.authentication?.backDomain || '127.0.0.1:' + config.port || 3000}` + + const redirectURI = `${baseUrl}/auth/oauth/callback` + + this.client = new OAuth2Client( + githubConfig.clientId, + authorizeEndpoint, + tokenEndpoint, + redirectURI, + ) + + this.clientSecret = githubConfig.clientSecret + } + + createAuthorizationURL( + state: string, + codeVerifier: string, + options?: { + scopes?: string[] + }, + ): URL { + const scopes = options?.scopes ?? [] + const url = this.client.createAuthorizationURL({ + state, + codeVerifier, + scopes: [...scopes, 'read:user', 'user:email'], + }) + + url.searchParams.set('access_type', 'offline') + url.searchParams.set('prompt', 'select_account') + + return url + } + + async validateAuthorizationCode( + code: string, + codeVerifier: string, + ): Promise { + const { access_token, expires_in, refresh_token, id_token } = + await this.client.validateAuthorizationCode( + code, + { + authenticateWith: 'request_body', + codeVerifier, + credentials: this.clientSecret, + }, + ) + + return { + accessToken: access_token, + refreshToken: refresh_token, + accessTokenExpiresAt: new Date(Date.now() + expires_in * 1000), + idToken: id_token, + } + } + + async refreshAccessToken(refreshToken: string): Promise { + const { access_token, expires_in } = + await this.client.refreshAccessToken( + refreshToken, + { + authenticateWith: 'request_body', + credentials: this.clientSecret, + }, + ) + + return { + accessToken: access_token, + accessTokenExpiresAt: new Date(Date.now() + expires_in * 1000), + } + } + + async getUserInfo(accessToken: string) { + const userInfoResponse = await fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github.v3+json', + }, + }) + + const userEmailResponse = await fetch( + 'https://api.github.com/user/emails', + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github.v3+json', + }, + }, + ) + + if (!userInfoResponse.ok || !userEmailResponse.ok) + throw new Error('Failed to fetch user information from GitHub') + + const userInfo = await userInfoResponse.json() + const userEmails = await userEmailResponse.json() + + const primaryEmail = userEmails.find((email: any) => email.primary)?.email + + return { + email: primaryEmail || null, + username: userInfo.login, + name: userInfo.name, + avatarUrl: userInfo.avatar_url, + } + } +} diff --git a/packages/wabe/src/authentication/providers/GitHub.test.ts b/packages/wabe/src/authentication/providers/GitHub.test.ts new file mode 100644 index 00000000..9d63c0ab --- /dev/null +++ b/packages/wabe/src/authentication/providers/GitHub.test.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test' +import { GitHub } from './GitHub' +import { GitHub as GitHubOauth } from '../oauth/GitHub' +import { AuthenticationProvider } from '../interface' + +describe('GitHub providers', () => { + const mockGetObjects = mock(() => Promise.resolve([])) + const mockCount = mock(() => Promise.resolve(0)) as any + const mockCreateObject = mock(() => Promise.resolve({ id: 'userId' })) as any + + const mockGetUserInfo = spyOn( + GitHubOauth.prototype, + 'getUserInfo', + ).mockResolvedValue({ + email: 'email@test.fr', + avatarUrl: 'avatarUrl', + username: 'username', + name: 'name', + }) + + const context = { + wabe: { + controllers: { + database: { + getObjects: mockGetObjects, + createObject: mockCreateObject, + count: mockCount, + }, + }, + config: { + authentication: { + providers: { + github: { + clientId: 'clientId', + clientSecret: 'clientSecret', + }, + }, + }, + }, + }, + } as any + + afterEach(() => { + mockGetObjects.mockRestore() + mockCreateObject.mockClear() + mockCount.mockClear() + mockGetUserInfo.mockClear() + }) + + it('should sign up with Google Provider if there is no user found', async () => { + const mockValidateAuthorizationCode = spyOn( + GitHubOauth.prototype, + 'validateAuthorizationCode', + ).mockResolvedValue({ + accessToken: 'accessToken', + refreshToken: 'refreshToken', + accessTokenExpiresAt: new Date(0), + }) + + const github = new GitHub() + + await github.onSignIn({ + context, + input: { + authorizationCode: 'authorizationCode', + codeVerifier: 'codeVerifier', + }, + }) + + expect(mockValidateAuthorizationCode).toHaveBeenCalledTimes(1) + expect(mockGetUserInfo).toHaveBeenCalledTimes(1) + + expect(mockGetObjects).toHaveBeenCalledTimes(1) + expect(mockGetObjects).toHaveBeenCalledWith({ + className: 'User', + where: { + authentication: { + github: { + email: { equalTo: 'email@test.fr' }, + }, + }, + }, + first: 1, + context: expect.any(Object), + fields: ['id'], + }) + + expect(mockCreateObject).toHaveBeenCalledTimes(1) + expect(mockCreateObject).toHaveBeenCalledWith({ + className: 'User', + data: { + provider: AuthenticationProvider.GitHub, + isOauth: true, + authentication: { + github: { + email: 'email@test.fr', + username: 'username', + avatarUrl: 'avatarUrl', + }, + }, + }, + context: expect.any(Object), + fields: ['*', 'id'], + }) + + mockValidateAuthorizationCode.mockRestore() + }) + + it('should sign in with Google Provider if there is no user found', async () => { + const mockValidateAuthorizationCode = spyOn( + GitHubOauth.prototype, + 'validateAuthorizationCode', + ).mockResolvedValue({ + accessToken: 'accessToken', + refreshToken: 'refreshToken', + accessTokenExpiresAt: new Date(0), + }) + + mockGetObjects.mockResolvedValue([ + { + id: 'userId', + authentication: { + github: { + email: 'email@test.fr', + verifiedEmail: true, + idToken: 'idToken', + }, + }, + provider: AuthenticationProvider.Google, + isOauth: true, + } as any, + ] as never) + + const github = new GitHub() + + await github.onSignIn({ + context, + input: { + authorizationCode: 'authorizationCode', + codeVerifier: 'codeVerifier', + }, + }) + + expect(mockValidateAuthorizationCode).toHaveBeenCalledTimes(1) + expect(mockGetUserInfo).toHaveBeenCalledTimes(1) + + expect(mockGetObjects).toHaveBeenCalledTimes(1) + expect(mockGetObjects).toHaveBeenCalledWith({ + className: 'User', + where: { + authentication: { + github: { + email: { equalTo: 'email@test.fr' }, + }, + }, + }, + first: 1, + context: expect.any(Object), + fields: ['id'], + }) + + expect(mockCreateObject).toHaveBeenCalledTimes(0) + + mockValidateAuthorizationCode.mockRestore() + }) +}) diff --git a/packages/wabe/src/authentication/providers/GitHub.ts b/packages/wabe/src/authentication/providers/GitHub.ts new file mode 100644 index 00000000..1d356372 --- /dev/null +++ b/packages/wabe/src/authentication/providers/GitHub.ts @@ -0,0 +1,92 @@ +import type { UserAuthenticationGithub } from '../../../generated/wabe' +import { contextWithRoot } from '../../utils/export' +import { + AuthenticationProvider, + type AuthenticationEventsOptions, + type ProviderInterface, +} from '../interface' +import { GitHub as GitHubOauth } from '../oauth/GitHub' + +type GitHubInterface = { + authorizationCode: string + codeVerifier: string +} + +export class GitHub implements ProviderInterface { + name = 'github' + async _githubAuthentication({ + context, + input, + }: AuthenticationEventsOptions) { + const { authorizationCode, codeVerifier } = input + + const githubOauth = new GitHubOauth(context.wabe.config) + + const { accessToken } = await githubOauth.validateAuthorizationCode( + authorizationCode, + codeVerifier, + ) + + const { email, avatarUrl, username } = + await githubOauth.getUserInfo(accessToken) + + const user = await context.wabe.controllers.database.getObjects({ + className: 'User', + where: { + authentication: { + // @ts-expect-error + github: { + email: { equalTo: email }, + }, + }, + }, + context: contextWithRoot(context), + first: 1, + fields: ['id'], + }) + + const authenticationDataToSave: UserAuthenticationGithub = { + email, + username, + avatarUrl, + } + + if (user.length === 0) { + const createdUser = await context.wabe.controllers.database.createObject({ + className: 'User', + data: { + provider: AuthenticationProvider.GitHub, + isOauth: true, + authentication: { + github: authenticationDataToSave, + }, + }, + context: contextWithRoot(context), + fields: ['*', 'id'], + }) + + if (!createdUser) throw new Error('User not found') + + return { + user: createdUser, + } + } + + if (!user[0]) throw new Error('User not found') + + return { + user: user[0], + } + } + + onSignIn(options: AuthenticationEventsOptions) { + return this._githubAuthentication(options) + } + + // @ts-expect-error + onSignUp() { + throw new Error( + 'SignUp is not implemented for Oauth provider, you should use signIn instead.', + ) + } +} diff --git a/packages/wabe/src/authentication/providers/Google.test.ts b/packages/wabe/src/authentication/providers/Google.test.ts index 71c34af0..274c7ef2 100644 --- a/packages/wabe/src/authentication/providers/Google.test.ts +++ b/packages/wabe/src/authentication/providers/Google.test.ts @@ -94,7 +94,6 @@ describe('Google providers', () => { google: { email: 'email@test.fr', verifiedEmail: true, - idToken: 'idToken', }, }, }, diff --git a/packages/wabe/src/authentication/providers/Google.ts b/packages/wabe/src/authentication/providers/Google.ts index 49465f49..7cd05584 100644 --- a/packages/wabe/src/authentication/providers/Google.ts +++ b/packages/wabe/src/authentication/providers/Google.ts @@ -53,7 +53,6 @@ export class Google implements ProviderInterface { const authenticationDataToSave: UserAuthenticationGoogle = { email, verifiedEmail, - idToken, } if (user.length === 0) { diff --git a/packages/wabe/src/authentication/providers/index.ts b/packages/wabe/src/authentication/providers/index.ts index dc36227f..14b0eb9c 100644 --- a/packages/wabe/src/authentication/providers/index.ts +++ b/packages/wabe/src/authentication/providers/index.ts @@ -1,2 +1,3 @@ export * from './EmailPassword' export * from './Google' +export * from './GitHub' diff --git a/packages/wabe/src/database/index.test.ts b/packages/wabe/src/database/index.test.ts index 80f78f65..665c686c 100644 --- a/packages/wabe/src/database/index.test.ts +++ b/packages/wabe/src/database/index.test.ts @@ -583,7 +583,6 @@ describe('Database', () => { google: { email: 'email@test.fr', verifiedEmail: true, - idToken: 'idToken', }, }, }, @@ -592,7 +591,6 @@ describe('Database', () => { expect(res?.authentication?.google).toEqual({ email: 'email@test.fr', verifiedEmail: true, - idToken: 'idToken', }) }) diff --git a/packages/wabe/src/server/routes/authHandler.ts b/packages/wabe/src/server/routes/authHandler.ts index 93081a4a..a8c4ad1c 100644 --- a/packages/wabe/src/server/routes/authHandler.ts +++ b/packages/wabe/src/server/routes/authHandler.ts @@ -5,6 +5,7 @@ import { getGraphqlClient } from '../../utils/helper' import { gql } from 'graphql-request' import { Google } from '../../authentication/oauth' import { generateRandomValues } from '../../authentication/oauth/utils' +import { GitHub } from '../../authentication/oauth/GitHub' /* - Generate code verifier (back) @@ -102,7 +103,7 @@ export const oauthHandlerCallback = async ( } } -export const authHandler = async ( +export const authHandler = ( context: Context, wabeContext: WabeContext, provider: ProviderEnum, @@ -137,7 +138,39 @@ export const authHandler = async ( secure: true, }) - const authorizationURL = await googleOauth.createAuthorizationURL( + const authorizationURL = googleOauth.createAuthorizationURL( + state, + codeVerifier, + { + scopes: ['email'], + }, + ) + + context.redirect(authorizationURL.toString()) + + break + } + case ProviderEnum.github: { + const githubOauth = new GitHub(wabeContext.wabe.config) + + const state = generateRandomValues() + const codeVerifier = generateRandomValues() + + context.res.setCookie('code_verifier', codeVerifier, { + httpOnly: true, + path: '/', + maxAge: 60 * 5, // 5 minutes + secure: true, + }) + + context.res.setCookie('state', state, { + httpOnly: true, + path: '/', + maxAge: 60 * 5, // 5 minutes + secure: true, + }) + + const authorizationURL = githubOauth.createAuthorizationURL( state, codeVerifier, {