Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down
8 changes: 8 additions & 0 deletions src/common/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '',
Expand Down
42 changes: 42 additions & 0 deletions src/connectors/__test__/userService.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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()
})
})
16 changes: 12 additions & 4 deletions src/connectors/userService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2682,9 +2682,13 @@ export class UserService extends BaseService<User> {
*/
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) {
Expand All @@ -2701,14 +2705,18 @@ export class UserService extends BaseService<User> {
}

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 = {
Expand Down
2 changes: 2 additions & 0 deletions src/definitions/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4120,6 +4120,8 @@ export type GQLSocialLoginInput = {
nonce?: InputMaybe<Scalars['String']['input']>
/** oauth token/verifier in OAuth1.0a for Twitter */
oauth1Credential?: InputMaybe<GQLOauth1CredentialInput>
/** 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<Scalars['String']['input']>
referralCode?: InputMaybe<Scalars['String']['input']>
type: GQLSocialAccountType
}
Expand Down
57 changes: 46 additions & 11 deletions src/mutations/user/socialLogin.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -16,6 +21,7 @@ export const socialLogin: GQLMutationResolvers['socialLogin'] = async (
oauth1Credential,
language,
referralCode,
redirectUri,
},
},
context
Expand Down Expand Up @@ -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
Expand All @@ -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 })
Expand Down
85 changes: 85 additions & 0 deletions src/types/__test__/2/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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!) {
Expand Down
2 changes: 2 additions & 0 deletions src/types/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading