diff --git a/schema.graphql b/schema.graphql index 40d3af350..cf071a044 100644 --- a/schema.graphql +++ b/schema.graphql @@ -3877,6 +3877,11 @@ input SocialLoginInput { """used in register""" language: UserLanguage referralCode: String + + """ + Google OIDC redirect_uri for OSS SSO. When set, must be allowlisted; login is restricted to existing admin accounts and no new account is created. + """ + redirectUri: String } input Oauth1CredentialInput { @@ -4478,7 +4483,9 @@ input QuotesInput { first: Int after: String - """random sampling for wall display; refetch to shuffle. when true, after is ignored""" + """ + random sampling for wall display; refetch to shuffle. when true, after is ignored + """ random: Boolean } diff --git a/src/common/environment.ts b/src/common/environment.ts index f4dab6f6a..9eeea3b3f 100644 --- a/src/common/environment.ts +++ b/src/common/environment.ts @@ -177,6 +177,14 @@ export const environment = { googleClientId: process.env.MATTERS_GOOGLE_CLIENT_ID || '', googleClientSecret: process.env.MATTERS_GOOGLE_CLIENT_SECRET || '', googleRedirectUri: process.env.MATTERS_GOOGLE_REDIRECT_URI || '', + // Allowlisted redirect_uri values for OSS Google SSO (comma-separated), e.g. + // "https://oss.matters.icu/callback/google,https://oss.matters.town/callback/google". + // Reuses the same Google OAuth client; the OSS callback URIs must also be + // registered on that client in Google Cloud Console. + ossGoogleRedirectUris: (process.env.MATTERS_OSS_GOOGLE_REDIRECT_URIS || '') + .split(',') + .map((uri) => uri.trim()) + .filter(Boolean), threadsClientId: process.env.MATTERS_THREADS_CLIENT_ID || '', threadsClientSecret: process.env.MATTERS_THREADS_CLIENT_SECRET || '', threadsRedirectUri: process.env.MATTERS_THREADS_REDIRECT_URI || '', diff --git a/src/connectors/__test__/userService.test.ts b/src/connectors/__test__/userService.test.ts index 28a0ee2e9..3d5ee4bb4 100644 --- a/src/connectors/__test__/userService.test.ts +++ b/src/connectors/__test__/userService.test.ts @@ -1,5 +1,10 @@ import type { Connections } from '#definitions/index.js' +import { jest } from '@jest/globals' +import axios from 'axios' +import jwt from 'jsonwebtoken' + +import { environment } from '#common/environment.js' import { CACHE_PREFIX, APPRECIATION_PURPOSE, @@ -1225,3 +1230,40 @@ describe('addBookmarkCountColumn', () => { expect(article3Result.bookmarkCount).toBe('0') }) }) + +describe('fetchGoogleUserInfo', () => { + test('exchanges the code with the given redirectUri (OSS SSO)', async () => { + const nonce = 'test-nonce' + const redirectUri = 'https://oss.matters.icu/callback/google' + const idToken = jwt.sign( + { + aud: environment.googleClientId, + nonce, + sub: 'google-sub-1', + email: 'oss-sso@gmail.com', + email_verified: true, + }, + 'test-secret' + ) + const spy = jest + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .spyOn(axios as any, 'post') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .mockResolvedValueOnce({ data: { id_token: idToken } } as any) + + const info = await userService.fetchGoogleUserInfo( + 'auth-code', + nonce, + redirectUri + ) + + expect(info.email).toBe('oss-sso@gmail.com') + expect(info.emailVerified).toBe(true) + expect(spy).toHaveBeenCalledWith( + 'https://oauth2.googleapis.com/token', + expect.objectContaining({ redirect_uri: redirectUri }), + expect.anything() + ) + spy.mockRestore() + }) +}) diff --git a/src/connectors/userService.ts b/src/connectors/userService.ts index 6b1dafacc..9cf728824 100644 --- a/src/connectors/userService.ts +++ b/src/connectors/userService.ts @@ -2682,9 +2682,13 @@ export class UserService extends BaseService { */ public fetchGoogleUserInfo = async ( authorizationCode: string, - nonce: string + nonce: string, + redirectUri?: string ) => { - const { id_token } = await this.exchangeGoogleToken(authorizationCode) + const { id_token } = await this.exchangeGoogleToken( + authorizationCode, + redirectUri + ) // eslint-disable-next-line @typescript-eslint/no-explicit-any const data = jwt.decode(id_token) as any if (data.aud !== environment.googleClientId) { @@ -2701,14 +2705,18 @@ export class UserService extends BaseService { } private exchangeGoogleToken = async ( - authorizationCode: string + authorizationCode: string, + redirectUri?: string ): Promise<{ access_token: string; id_token: string }> => { const url = 'https://oauth2.googleapis.com/token' const data = { code: authorizationCode, client_id: environment.googleClientId, client_secret: environment.googleClientSecret, - redirect_uri: environment.googleRedirectUri, + // For OSS SSO the redirect_uri must match the one used in the authorization + // request; callers pass an allowlisted OSS callback. Falls back to the + // default (matters-web) redirect_uri otherwise. + redirect_uri: redirectUri || environment.googleRedirectUri, grant_type: 'authorization_code', } const headers = { diff --git a/src/definitions/schema.d.ts b/src/definitions/schema.d.ts index 50a127e38..7bb6b310d 100644 --- a/src/definitions/schema.d.ts +++ b/src/definitions/schema.d.ts @@ -4120,6 +4120,8 @@ export type GQLSocialLoginInput = { nonce?: InputMaybe /** oauth token/verifier in OAuth1.0a for Twitter */ oauth1Credential?: InputMaybe + /** Google OIDC redirect_uri for OSS SSO. When set, must be allowlisted; login is restricted to existing admin accounts and no new account is created. */ + redirectUri?: InputMaybe referralCode?: InputMaybe type: GQLSocialAccountType } diff --git a/src/mutations/user/socialLogin.ts b/src/mutations/user/socialLogin.ts index 392bb9780..a54e650bf 100644 --- a/src/mutations/user/socialLogin.ts +++ b/src/mutations/user/socialLogin.ts @@ -1,7 +1,12 @@ import type { GQLMutationResolvers, AuthMode } from '#definitions/index.js' -import { AUTH_RESULT_TYPE, SOCIAL_LOGIN_TYPE } from '#common/enums/index.js' -import { UserInputError } from '#common/errors.js' +import { + AUTH_RESULT_TYPE, + SOCIAL_LOGIN_TYPE, + USER_ROLE, +} from '#common/enums/index.js' +import { environment } from '#common/environment.js' +import { ForbiddenError, UserInputError } from '#common/errors.js' import { checkIfE2ETest, throwOrReturnUserInfo } from '#common/utils/e2e.js' import { setCookie, getViewerFromUser } from '#common/utils/index.js' @@ -16,6 +21,7 @@ export const socialLogin: GQLMutationResolvers['socialLogin'] = async ( oauth1Credential, language, referralCode, + redirectUri, }, }, context @@ -112,6 +118,17 @@ export const socialLogin: GQLMutationResolvers['socialLogin'] = async ( if (nonce === undefined || authorizationCode === undefined) { throw new UserInputError('nonce and authorizationCode is required') } + // OSS SSO: when a redirectUri is supplied it must be allowlisted. This both + // (a) lets the token exchange use the OSS callback — Google requires the + // token-exchange redirect_uri to match the authorization request — and + // (b) flags this as an OSS admin login (existing admins only, no creation). + const isOSSLogin = !!redirectUri + if ( + isOSSLogin && + !environment.ossGoogleRedirectUris.includes(redirectUri) + ) { + throw new UserInputError('redirectUri is not allowed') + } let userInfo: { id: string email: string @@ -120,16 +137,34 @@ export const socialLogin: GQLMutationResolvers['socialLogin'] = async ( if (isE2ETest) { userInfo = throwOrReturnUserInfo(authorizationCode, type) as any } else { - userInfo = await userService.fetchGoogleUserInfo(authorizationCode, nonce) + userInfo = await userService.fetchGoogleUserInfo( + authorizationCode, + nonce, + isOSSLogin ? redirectUri : undefined + ) + } + if (isOSSLogin) { + // Restrict OSS login to existing admin accounts; never auto-create one. + if (!userInfo.emailVerified) { + throw new ForbiddenError('Google email is not verified') + } + const existingUser = await userService.findByEmail(userInfo.email) + if (!existingUser || existingUser.role !== USER_ROLE.admin) { + throw new ForbiddenError( + 'OSS login is restricted to existing admin accounts' + ) + } + user = existingUser + } else { + user = await userService.getOrCreateUserBySocialAccount({ + providerAccountId: userInfo.id, + type: SOCIAL_LOGIN_TYPE.Google, + email: userInfo.email, + emailVerified: userInfo.emailVerified, + language: language || viewer.language, + referralCode, + }) } - user = await userService.getOrCreateUserBySocialAccount({ - providerAccountId: userInfo.id, - type: SOCIAL_LOGIN_TYPE.Google, - email: userInfo.email, - emailVerified: userInfo.emailVerified, - language: language || viewer.language, - referralCode, - }) } const sessionToken = await userService.genSessionToken(user.id) setCookie({ req, res, token: sessionToken, user }) diff --git a/src/types/__test__/2/auth.test.ts b/src/types/__test__/2/auth.test.ts index fe190d800..906a4050a 100644 --- a/src/types/__test__/2/auth.test.ts +++ b/src/types/__test__/2/auth.test.ts @@ -7,8 +7,10 @@ import { AUTH_MODE, NODE_TYPES, SCOPE_PREFIX, + USER_ROLE, VERIFICATION_CODE_STATUS, } from '#common/enums/index.js' +import { environment } from '#common/environment.js' import { toGlobalId } from '#common/utils/index.js' import { UserService, SystemService } from '#connectors/index.js' @@ -1161,6 +1163,89 @@ describe('socialLogin with Threads', () => { }) }) +describe('socialLogin with Google (OSS SSO)', () => { + const SOCIAL_LOGIN = /* GraphQL */ ` + mutation ($input: SocialLoginInput!) { + socialLogin(input: $input) { + auth + token + } + } + ` + const OSS_REDIRECT_URI = 'https://oss.matters.icu/callback/google' + + beforeAll(() => { + // simulate MATTERS_OSS_GOOGLE_REDIRECT_URIS being configured + if (!environment.ossGoogleRedirectUris.includes(OSS_REDIRECT_URI)) { + environment.ossGoogleRedirectUris.push(OSS_REDIRECT_URI) + } + }) + + // A redirectUri that is not in the allowlist must be rejected before token + // exchange, guarding against an attacker-controlled redirect_uri. + test('rejects a non-allowlisted redirectUri', async () => { + const server = await testClient({ connections }) + const { errors } = await server.executeOperation({ + query: SOCIAL_LOGIN, + variables: { + input: { + type: 'Google', + authorizationCode: 'e2etest-oss-google', + nonce: 'e2etest-nonce', + redirectUri: 'https://attacker.example/callback/google', + }, + }, + }) + expect(errors && errors.length).toBeGreaterThanOrEqual(1) + expect(errors?.[0]?.message).toContain('redirectUri') + }) + + // OSS login must be restricted to existing admin accounts; an unknown or + // non-admin Google account is rejected and never auto-created. + test('rejects a non-admin account', async () => { + const server = await testClient({ connections }) + const { errors } = await server.executeOperation({ + query: SOCIAL_LOGIN, + variables: { + input: { + type: 'Google', + authorizationCode: 'e2etest-oss-nonadmin', + nonce: 'e2etest-nonce', + redirectUri: OSS_REDIRECT_URI, + }, + }, + }) + expect(errors && errors.length).toBeGreaterThanOrEqual(1) + expect(errors?.[0]?.message).toContain('admin') + }) + + // An existing admin account whose verified Google email matches logs in. + test('logs in an existing admin account', async () => { + const code = 'e2etest-oss-admin' + const email = `${code}@gmail.com` + const created = await userService.create({ email, emailVerified: true }) + await userService.baseUpdate(created.id, { + role: USER_ROLE.admin as 'admin', + }) + + const server = await testClient({ connections }) + const { data, errors } = await server.executeOperation({ + query: SOCIAL_LOGIN, + variables: { + input: { + type: 'Google', + authorizationCode: code, + nonce: 'e2etest-nonce', + redirectUri: OSS_REDIRECT_URI, + }, + }, + }) + expect(errors).toBeUndefined() + expect(data?.socialLogin.auth).toBe(true) + expect(data?.socialLogin.token).toBeTruthy() + }) +}) + describe('add social accounts', () => { const ADD_SOCIAL_LOGIN = /* GraphQL */ ` mutation ($input: SocialLoginInput!) { diff --git a/src/types/user.ts b/src/types/user.ts index ce8bd9bcc..0471f7e67 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -1068,6 +1068,8 @@ export default /* GraphQL */ ` "used in register" language: UserLanguage referralCode: String + "Google OIDC redirect_uri for OSS SSO. When set, must be allowlisted; login is restricted to existing admin accounts and no new account is created." + redirectUri: String } input Oauth1CredentialInput {