From 85adc9741cad3ac653ad22b21cb913bd99e35316 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Fri, 5 Jun 2026 07:54:23 +0000 Subject: [PATCH] feat: add self-hosted google auth --- .env.example | 10 +- PR_ENVIRONMENTS.md | 1 + README.md | 82 +++++- apps/api/src/api/endpoints.ts | 10 + apps/api/src/api/handlers/auth.test.ts | 46 +++- apps/api/src/api/handlers/auth.ts | 33 ++- apps/api/src/api/handlers/index.ts | 2 + apps/api/src/application/users.test.ts | 127 ++++++++++ apps/api/src/application/users.ts | 73 ++++-- apps/api/src/config.ts | 12 +- apps/web/app/(auth)/login/page.tsx | 25 +- apps/web/auth.ts | 335 ++++++++++++++----------- apps/web/components/landing-page.tsx | 2 +- apps/web/lib/actions.ts | 24 +- apps/web/lib/config.ts | 29 ++- apps/web/lib/google-auth.test.ts | 92 +++++++ apps/web/lib/google-auth.ts | 108 ++++++++ docker-compose.prod.yml | 15 +- docker-compose.yml | 15 +- packages/contracts/src/api.test.ts | 1 + packages/contracts/src/api.ts | 9 + packages/contracts/src/schemas.test.ts | 27 ++ packages/contracts/src/schemas.ts | 31 +++ pr-env.sh | 3 + 24 files changed, 897 insertions(+), 215 deletions(-) create mode 100644 apps/web/lib/google-auth.test.ts create mode 100644 apps/web/lib/google-auth.ts diff --git a/.env.example b/.env.example index e075653..8bb7366 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ APP_URL=http://localhost:3000 API_BASE_URL=http://localhost:4000 NEXTAUTH_URL=http://localhost:3000 NEXTAUTH_SECRET=change-me-in-production-min32chars +AUTH_SECRET=change-me-in-production-min32chars POSTGRES_DB=xpenser POSTGRES_USER=xpenser POSTGRES_PASSWORD=xpenser_secret @@ -14,9 +15,12 @@ DB_PASSWORD=xpenser_secret JWT_SECRET=change-me-in-production-min32chars JWT_EXPIRES_IN=1209600 WEB_API_SERVICE_SECRET=change-me-in-production-min32chars -PASSPORT_BASE_URL=https://auth.cleverbrush.com -PASSPORT_PROJECT=xpenser -PASSPORT_ENVIRONMENT=production +GOOGLE_SIGN_IN_MODE=auto +AUTH_GOOGLE_ID= +AUTH_GOOGLE_SECRET= +PASSPORT_BASE_URL= +PASSPORT_PROJECT= +PASSPORT_ENVIRONMENT= PASSPORT_PUBLIC_KEY= FRANKFURTER_BASE_URL=https://api.frankfurter.dev/v2 BRANDFETCH_API_KEY= diff --git a/PR_ENVIRONMENTS.md b/PR_ENVIRONMENTS.md index 1a747a9..9555575 100644 --- a/PR_ENVIRONMENTS.md +++ b/PR_ENVIRONMENTS.md @@ -433,6 +433,7 @@ PR_ENV_STATE_DIR=/var/lib/pr-envs PR_ENV_PORT_BASE=3000 PROD_COMPOSE_PROJECT=xpenser GIT_REPOSITORY_URL=git@github.com:cleverbrush/xpenser.git +GOOGLE_SIGN_IN_MODE=passport PASSPORT_BASE_URL=https://auth.cleverbrush.com PASSPORT_PROJECT=xpenser POSTGRES_DB=xpenser diff --git a/README.md b/README.md index 6ea483b..fbba4fe 100644 --- a/README.md +++ b/README.md @@ -77,16 +77,77 @@ Local URLs: - API health check: http://localhost:4000/health - OpenAPI JSON: http://localhost:4000/openapi.json -### Google sign-in through Passport +### Authentication -Google sign-in is brokered by Passport at `auth.cleverbrush.com`. The web app -redirects users to Passport, Passport calls the xpenser API to resolve or -auto-create the local user, and the callback exchanges the Passport code for the -same xpenser API JWT used by email/password login. +Email/password sign-in is built in and works without any external auth provider. +Accounts created this way must confirm their email before signing in. -Configure the xpenser services with: +Google sign-in has two supported modes: + +- Direct Google OAuth for self-hosted deployments. +- Cleverbrush Passport for the hosted Cleverbrush deployment. + +Select the mode with `GOOGLE_SIGN_IN_MODE`: + +```env +GOOGLE_SIGN_IN_MODE=auto +``` + +`auto` uses direct Google OAuth when `AUTH_GOOGLE_ID` and +`AUTH_GOOGLE_SECRET` are configured. If those are not set, it uses Passport only +when all Passport variables are configured. If neither auth provider is +configured, the Google sign-in button is hidden and email/password sign-in still +works. + +Use `GOOGLE_SIGN_IN_MODE=direct` to require direct Google OAuth, +`GOOGLE_SIGN_IN_MODE=passport` to require Passport, or +`GOOGLE_SIGN_IN_MODE=disabled` to hide Google sign-in even when credentials are +present. + +#### Direct Google OAuth for self-hosting + +Create an OAuth 2.0 client in Google Cloud Console: + +- Application type: Web application +- Authorized JavaScript origin: your public `APP_URL` +- Authorized redirect URI: `${APP_URL}/api/auth/callback/google` + +For local development with the default `APP_URL`, use: + +```text +http://localhost:3000/api/auth/callback/google +``` + +Configure the web app with Auth.js-standard Google variables: + +```env +APP_URL=https://xpenser.example.com +NEXTAUTH_URL=https://xpenser.example.com +NEXTAUTH_SECRET=replace-with-at-least-32-characters +AUTH_SECRET=replace-with-the-same-value-as-NEXTAUTH_SECRET +GOOGLE_SIGN_IN_MODE=auto +AUTH_GOOGLE_ID=your-google-oauth-client-id +AUTH_GOOGLE_SECRET=your-google-oauth-client-secret +``` + +The web app validates the Google profile through Auth.js, then calls the private +xpenser API with `WEB_API_SERVICE_SECRET`. The API resolves or creates a local +`google` user, stores the Google subject in `external_identities`, and returns +the same xpenser API JWT used by email/password sessions. + +Google accounts must have a verified email address. If a local email/password +account already exists with the same email, Google sign-in is rejected instead of +silently linking the accounts. + +#### Passport for Cleverbrush deployment + +Passport is a private Cleverbrush auth broker. Self-hosted deployments should +use direct Google OAuth unless they run their own compatible Passport service. + +Configure both services with: ```env +GOOGLE_SIGN_IN_MODE=passport PASSPORT_BASE_URL=https://auth.cleverbrush.com PASSPORT_PROJECT=xpenser PASSPORT_ENVIRONMENT=production @@ -263,3 +324,12 @@ change the relevant app port before starting the dev servers. If login/register fails after changing secrets or resetting data, stop the dev server, clear browser cookies for `localhost`, and start the app again. + +If the Google sign-in button is hidden, either set `AUTH_GOOGLE_ID` and +`AUTH_GOOGLE_SECRET` for direct Google OAuth, set complete Passport variables +with `GOOGLE_SIGN_IN_MODE=passport`, or set `GOOGLE_SIGN_IN_MODE=direct` to fail +fast when Google credentials are missing. + +If Google returns a redirect URI mismatch, add the exact +`${APP_URL}/api/auth/callback/google` URL to the Google OAuth client. The scheme, +host, port, and path must match the public URL users open in the browser. diff --git a/apps/api/src/api/endpoints.ts b/apps/api/src/api/endpoints.ts index 16dad81..d7db992 100644 --- a/apps/api/src/api/endpoints.ts +++ b/apps/api/src/api/endpoints.ts @@ -49,6 +49,15 @@ export const PassportExchangeEndpoint = api.auth.passportExchange .tags('auth') .operationId('passportExchange'); +export const GoogleSignInEndpoint = api.auth.googleSignIn + .inject({ db: DbToken, config: ConfigToken }) + .summary('Direct Google sign-in') + .description( + 'Maps an Auth.js Google identity to a local xpenser user and issues an API JWT.' + ) + .tags('auth') + .operationId('googleSignIn'); + export const SessionTokenEndpoint = api.auth.sessionToken .inject({ db: DbToken, config: ConfigToken }) .summary('Web session token') @@ -393,6 +402,7 @@ export const endpoints = { resendEmailConfirmation: ResendEmailConfirmationEndpoint, passportResolveUser: PassportResolveUserEndpoint, passportExchange: PassportExchangeEndpoint, + googleSignIn: GoogleSignInEndpoint, sessionToken: SessionTokenEndpoint, me: GetMeEndpoint }, diff --git a/apps/api/src/api/handlers/auth.test.ts b/apps/api/src/api/handlers/auth.test.ts index c43b206..bddbd2e 100644 --- a/apps/api/src/api/handlers/auth.test.ts +++ b/apps/api/src/api/handlers/auth.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it, vi } from 'vitest'; import type { Config } from '../../config.js'; import type { AppDb } from '../../db/schemas.js'; import { hashPassword } from '../../security/password.js'; -import { loginHandler, sessionTokenHandler } from './auth.js'; +import { + googleSignInHandler, + loginHandler, + sessionTokenHandler +} from './auth.js'; const secret = 'web-service-secret-minimum-32-chars'; const config = { @@ -97,6 +101,46 @@ describe('session token handler', () => { }); }); +describe('direct Google sign-in handler', () => { + const body = { + providerSubject: 'google-subject', + email: 'jane@example.com', + emailVerified: true + }; + + it('rejects missing web service credentials', async () => { + const result = await googleSignInHandler( + { + body, + context: { headers: {} } + } as never, + { db: mockDb(undefined), config } as never + ); + + expect(result).toMatchObject({ + status: 401, + body: { message: 'Invalid web service credentials.' } + }); + }); + + it('rejects invalid web service credentials', async () => { + const result = await googleSignInHandler( + { + body, + context: { + headers: { 'x-xpenser-web-secret': `${secret}-wrong` } + } + } as never, + { db: mockDb(undefined), config } as never + ); + + expect(result).toMatchObject({ + status: 401, + body: { message: 'Invalid web service credentials.' } + }); + }); +}); + describe('login handler', () => { it('rejects valid credentials until local email is confirmed', async () => { const passwordHash = await hashPassword('correct horse battery staple'); diff --git a/apps/api/src/api/handlers/auth.ts b/apps/api/src/api/handlers/auth.ts index 6b3c36f..61e7ae3 100644 --- a/apps/api/src/api/handlers/auth.ts +++ b/apps/api/src/api/handlers/auth.ts @@ -6,7 +6,9 @@ import { getUserPreference, InvalidCredentialsError, InvalidEmailConfirmationTokenError, + InvalidGoogleIdentityError, InvalidPassportIdentityError, + issueGoogleUserToken, issuePassportUserToken, issueUserToken, loginUser, @@ -26,6 +28,7 @@ import { import type { ConfirmEmailEndpoint, GetMeEndpoint, + GoogleSignInEndpoint, LoginEndpoint, PassportExchangeEndpoint, PassportResolveUserEndpoint, @@ -104,7 +107,10 @@ export const passportResolveUserHandler: Handler< ); return await resolvePassportGoogleUser(db, body); } catch (err) { - if (err instanceof InvalidPassportIdentityError) { + if ( + err instanceof InvalidGoogleIdentityError || + err instanceof InvalidPassportIdentityError + ) { return ActionResult.badRequest({ message: err.message }); } if (err instanceof PassportAuthError) { @@ -142,6 +148,31 @@ export const passportExchangeHandler: Handler< } }; +export const googleSignInHandler: Handler = async ( + { body, context }, + { db, config } +) => { + if ( + !verifyWebApiServiceSecret( + config, + context.headers[webServiceSecretHeader] + ) + ) { + return ActionResult.unauthorized({ + message: 'Invalid web service credentials.' + }); + } + + try { + return await issueGoogleUserToken(db, config, body); + } catch (err) { + if (err instanceof InvalidGoogleIdentityError) { + return ActionResult.badRequest({ message: err.message }); + } + throw err; + } +}; + export const sessionTokenHandler: Handler = async ( { body, context }, { db, config } diff --git a/apps/api/src/api/handlers/index.ts b/apps/api/src/api/handlers/index.ts index 346d4df..3042698 100644 --- a/apps/api/src/api/handlers/index.ts +++ b/apps/api/src/api/handlers/index.ts @@ -6,6 +6,7 @@ import { import { confirmEmailHandler, getMeHandler, + googleSignInHandler, loginHandler, passportExchangeHandler, passportResolveUserHandler, @@ -65,6 +66,7 @@ export const handlers = { resendEmailConfirmation: resendEmailConfirmationHandler, passportResolveUser: passportResolveUserHandler, passportExchange: passportExchangeHandler, + googleSignIn: googleSignInHandler, sessionToken: sessionTokenHandler, me: getMeHandler }, diff --git a/apps/api/src/application/users.test.ts b/apps/api/src/application/users.test.ts index c2eb314..2c64458 100644 --- a/apps/api/src/application/users.test.ts +++ b/apps/api/src/application/users.test.ts @@ -7,10 +7,12 @@ import { createEmailConfirmationToken, EmailNotVerifiedError, hashEmailConfirmationToken, + InvalidGoogleIdentityError, InvalidPassportIdentityError, issueUserToken, loginUser, PasswordMismatchError, + resolveGoogleUser, transactionCurrenciesByRecentPopularity, verifyWebApiServiceSecret } from './users.js'; @@ -224,6 +226,131 @@ describe('email confirmation', () => { }); }); +describe('Google identity resolution', () => { + function mockGoogleDb({ + existingIdentity, + existingUserIdentity, + linkedUser, + userByEmail + }: { + readonly existingIdentity?: object; + readonly existingUserIdentity?: object; + readonly linkedUser?: object; + readonly userByEmail?: object; + }): { db: AppDb; insertIdentity: ReturnType } { + const insertedUser = { + id: 12, + email: 'jane@example.com', + role: 'user', + authProvider: 'google' + }; + const insertIdentity = vi.fn(); + const trx = { + externalIdentities: { + where: vi + .fn() + .mockReturnValueOnce({ + where: vi.fn(() => ({ + first: vi.fn(async () => existingIdentity) + })) + }) + .mockReturnValueOnce({ + where: vi.fn(() => ({ + first: vi.fn(async () => existingUserIdentity) + })) + }), + insert: insertIdentity + }, + users: { + find: vi.fn(async () => linkedUser), + where: vi.fn(() => ({ + first: vi.fn(async () => userByEmail) + })), + insert: vi.fn(async () => insertedUser) + } + }; + + return { + db: { + transaction: async (callback: (db: typeof trx) => T) => + callback(trx) + } as unknown as AppDb, + insertIdentity + }; + } + + it('creates a Google user and external identity for a new verified identity', async () => { + const { db, insertIdentity } = mockGoogleDb({}); + + const response = await resolveGoogleUser(db, { + providerSubject: 'google-subject', + email: 'jane@example.com', + emailVerified: true + }); + + expect(response).toEqual({ + service_user_id: '12', + roles: ['user'] + }); + expect(insertIdentity).toHaveBeenCalledWith({ + provider: 'google', + providerSubject: 'google-subject', + userId: 12, + email: 'jane@example.com' + }); + }); + + it('reuses an existing linked Google identity', async () => { + const { db, insertIdentity } = mockGoogleDb({ + existingIdentity: { userId: 15 }, + linkedUser: { + id: 15, + role: 'admin' + } + }); + + await expect( + resolveGoogleUser(db, { + providerSubject: 'google-subject', + email: 'jane@example.com', + emailVerified: true + }) + ).resolves.toEqual({ + service_user_id: '15', + roles: ['admin'] + }); + expect(insertIdentity).not.toHaveBeenCalled(); + }); + + it('rejects unverified Google emails', async () => { + await expect( + resolveGoogleUser({} as AppDb, { + providerSubject: 'google-subject', + email: 'jane@example.com', + emailVerified: false + }) + ).rejects.toBeInstanceOf(InvalidGoogleIdentityError); + }); + + it('rejects local accounts with the same email', async () => { + const { db } = mockGoogleDb({ + userByEmail: { + id: 12, + email: 'jane@example.com', + authProvider: 'local' + } + }); + + await expect( + resolveGoogleUser(db, { + providerSubject: 'google-subject', + email: 'jane@example.com', + emailVerified: true + }) + ).rejects.toBeInstanceOf(InvalidGoogleIdentityError); + }); +}); + describe('transaction currency ordering', () => { it('sorts configured currencies by recent transaction popularity', () => { expect( diff --git a/apps/api/src/application/users.ts b/apps/api/src/application/users.ts index 118bda2..76b1a3e 100644 --- a/apps/api/src/application/users.ts +++ b/apps/api/src/application/users.ts @@ -2,6 +2,7 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; import type { EmailConfirmationMessageResponse, EmailConfirmationPendingResponse, + GoogleSignInBody, PassportResolveUserBody, PassportResolveUserResponse, RegisterBody, @@ -19,6 +20,7 @@ export class DuplicateEmailError extends Error {} export class EmailNotVerifiedError extends Error {} export class InvalidEmailConfirmationTokenError extends Error {} export class InvalidCredentialsError extends Error {} +export class InvalidGoogleIdentityError extends Error {} export class InvalidPassportIdentityError extends Error {} export class PasswordMismatchError extends Error {} @@ -440,28 +442,23 @@ export async function resendEmailConfirmation( return emailConfirmationResendResponse(); } -export async function resolvePassportGoogleUser( +async function resolveGoogleIdentity( db: AppDb, - identity: PassportResolveUserBody + identity: GoogleSignInBody ): Promise { - if (identity.provider !== 'google') { - throw new InvalidPassportIdentityError( - 'Unsupported identity provider.' - ); - } - if (identity.email_verified !== true) { - throw new InvalidPassportIdentityError('Google email is not verified.'); + if (identity.emailVerified !== true) { + throw new InvalidGoogleIdentityError('Google email is not verified.'); } return db.transaction(async trx => { const existingIdentity = await trx.externalIdentities - .where(row => row.provider, identity.provider) - .where(row => row.providerSubject, identity.provider_subject) + .where(row => row.provider, 'google') + .where(row => row.providerSubject, identity.providerSubject) .first(); if (existingIdentity) { const user = await trx.users.find(existingIdentity.userId); if (!user) { - throw new InvalidPassportIdentityError( + throw new InvalidGoogleIdentityError( 'Linked account was not found.' ); } @@ -475,7 +472,7 @@ export async function resolvePassportGoogleUser( .where(user => user.email, identity.email) .first(); if (found && found.authProvider !== 'google') { - throw new InvalidPassportIdentityError( + throw new InvalidGoogleIdentityError( 'Email is already registered with another sign-in method.' ); } @@ -496,18 +493,18 @@ export async function resolvePassportGoogleUser( })); const userIdentity = await trx.externalIdentities - .where(row => row.provider, identity.provider) + .where(row => row.provider, 'google') .where(row => row.userId, user.id) .first(); if (userIdentity) { - throw new InvalidPassportIdentityError( + throw new InvalidGoogleIdentityError( 'Account is already linked to another Google identity.' ); } await trx.externalIdentities.insert({ - provider: identity.provider, - providerSubject: identity.provider_subject, + provider: 'google', + providerSubject: identity.providerSubject, userId: user.id, email: identity.email }); @@ -519,6 +516,48 @@ export async function resolvePassportGoogleUser( }); } +export async function resolveGoogleUser( + db: AppDb, + identity: GoogleSignInBody +): Promise { + return resolveGoogleIdentity(db, identity); +} + +export async function resolvePassportGoogleUser( + db: AppDb, + identity: PassportResolveUserBody +): Promise { + if (identity.provider !== 'google') { + throw new InvalidGoogleIdentityError('Unsupported identity provider.'); + } + + return resolveGoogleIdentity(db, { + providerSubject: identity.provider_subject, + email: identity.email, + emailVerified: identity.email_verified, + name: identity.name, + avatarUrl: identity.avatar_url + }); +} + +export async function issueGoogleUserToken( + db: AppDb, + config: Config, + identity: GoogleSignInBody +): Promise { + const resolved = await resolveGoogleUser(db, identity); + const userId = Number(resolved.service_user_id); + if (!Number.isSafeInteger(userId) || userId <= 0) { + throw new InvalidGoogleIdentityError('Google user was not found.'); + } + + const response = await issueUserToken(db, config, userId); + if (!response) { + throw new InvalidGoogleIdentityError('Google user was not found.'); + } + return response; +} + export async function issuePassportUserToken( db: AppDb, config: Config, diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 3ecb935..b1094c0 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -45,15 +45,9 @@ export const config = parseEnv( ) }, passport: { - baseUrl: env( - 'PASSPORT_BASE_URL', - string().default('https://auth.cleverbrush.com') - ), - project: env('PASSPORT_PROJECT', string().default('xpenser')), - environment: env( - 'PASSPORT_ENVIRONMENT', - string().default('production') - ), + baseUrl: env('PASSPORT_BASE_URL', string().default('')), + project: env('PASSPORT_PROJECT', string().default('')), + environment: env('PASSPORT_ENVIRONMENT', string().default('')), publicKey: env('PASSPORT_PUBLIC_KEY', string().optional()) }, telegram: { diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 0751315..354da06 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -9,8 +9,13 @@ import { import Link from 'next/link'; import { LoginForm } from '@/components/forms/login-form'; import { googleSignInAction } from '@/lib/actions'; +import { getGoogleSignInProvider } from '@/lib/config'; + +export const dynamic = 'force-dynamic'; export default function LoginPage() { + const googleSignInEnabled = getGoogleSignInProvider() !== 'disabled'; + return (
@@ -20,15 +25,17 @@ export default function LoginPage() { -
- -
+ {googleSignInEnabled ? ( +
+ +
+ ) : null}

New here?{' '} { return applyTokenResponse(token, response); } -const nextAuth: NextAuthResult = NextAuth(() => ({ - session: { strategy: 'jwt', maxAge: UserSessionMaxAgeSeconds }, - jwt: { maxAge: UserSessionMaxAgeSeconds }, - secret: getNextAuthSecret(), - trustHost: true, - logger: { - error(error) { - authLogger.error(error, AuthErrorLogged, { - AuthErrorType: authErrorType(error), - AuthErrorMessage: error.message - }); - }, - warn(code) { - authLogger.warn(AuthWarningLogged, { - AuthWarningCode: code - }); - }, - debug(message, metadata) { - authLogger.debug(AuthDebugLogged, { - AuthDebugMessage: message, - AuthDebugMetadata: metadata - }); - } - }, - providers: [ - Credentials({ - credentials: { - email: { label: 'Email', type: 'email' }, - password: { label: 'Password', type: 'password' } - }, - authorize: async credentials => { - const email = String(credentials?.email ?? ''); - const password = String(credentials?.password ?? ''); - const response = await apiClient().auth.login({ - body: { email, password } - }); +const nextAuth: NextAuthResult = NextAuth(() => { + const googleSignInProvider = getGoogleSignInProvider(); - return { - id: String(response.user.id), - email: response.user.email, - apiToken: response.token, - apiTokenExpiresAt: apiTokenExpiresAt(response.expiresAt), - role: response.user.role, - defaultCurrency: response.user.defaultCurrency, - countryCode: response.user.countryCode, - timezone: response.user.timezone, - hasCategories: response.user.hasCategories - }; - } - }), - Credentials({ - id: 'passport-code', - name: 'Passport', - credentials: { - code: { label: 'Code', type: 'text' }, - codeVerifier: { label: 'Code verifier', type: 'text' } + return { + session: { strategy: 'jwt', maxAge: UserSessionMaxAgeSeconds }, + jwt: { maxAge: UserSessionMaxAgeSeconds }, + secret: getNextAuthSecret(), + trustHost: true, + logger: { + error(error) { + authLogger.error(error, AuthErrorLogged, { + AuthErrorType: authErrorType(error), + AuthErrorMessage: error.message + }); }, - authorize: async credentials => { - const code = String(credentials?.code ?? ''); - const codeVerifier = String(credentials?.codeVerifier ?? ''); - const response = await apiClient().auth.passportExchange({ - body: { code, codeVerifier } + warn(code) { + authLogger.warn(AuthWarningLogged, { + AuthWarningCode: code }); - - return { - id: String(response.user.id), - email: response.user.email, - apiToken: response.token, - apiTokenExpiresAt: apiTokenExpiresAt(response.expiresAt), - role: response.user.role, - defaultCurrency: response.user.defaultCurrency, - countryCode: response.user.countryCode, - timezone: response.user.timezone, - hasCategories: response.user.hasCategories - }; - } - }), - Credentials({ - id: 'email-confirmation-token', - name: 'Email confirmation', - credentials: { - token: { label: 'Token', type: 'text' } }, - authorize: async credentials => { - const token = String(credentials?.token ?? ''); - const response = await apiClient().auth.confirmEmail({ - body: { token } + debug(message, metadata) { + authLogger.debug(AuthDebugLogged, { + AuthDebugMessage: message, + AuthDebugMetadata: metadata }); - - return { - id: String(response.user.id), - email: response.user.email, - apiToken: response.token, - apiTokenExpiresAt: apiTokenExpiresAt(response.expiresAt), - role: response.user.role, - defaultCurrency: response.user.defaultCurrency, - countryCode: response.user.countryCode, - timezone: response.user.timezone, - hasCategories: response.user.hasCategories - }; - } - }) - ], - pages: { - signIn: '/login', - error: expiredSessionPath - }, - callbacks: { - async jwt({ token, user }) { - if (user?.apiToken) { - token.apiToken = user.apiToken; - token.apiTokenExpiresAt = user.apiTokenExpiresAt; - token.sub = user.id; - token.email = user.email; - token.role = user.role; - token.defaultCurrency = user.defaultCurrency; - token.countryCode = user.countryCode; - token.timezone = user.timezone; - token.hasCategories = user.hasCategories; } - - if (shouldRefreshApiToken(token)) { - try { - return await refreshApiToken(token); - } catch (err) { - authLogger.warn(AuthWarningLogged, { - AuthWarningCode: - apiErrorStatus(err) === 401 - ? 'ApiTokenRefreshUnauthorized' - : 'ApiTokenRefreshFailed' + }, + providers: [ + Credentials({ + credentials: { + email: { label: 'Email', type: 'email' }, + password: { label: 'Password', type: 'password' } + }, + authorize: async credentials => { + const email = String(credentials?.email ?? ''); + const password = String(credentials?.password ?? ''); + const response = await apiClient().auth.login({ + body: { email, password } }); - if (apiErrorStatus(err) === 401) { - token.apiToken = undefined; - token.apiTokenExpiresAt = undefined; - } + + return { + id: String(response.user.id), + email: response.user.email, + apiToken: response.token, + apiTokenExpiresAt: apiTokenExpiresAt( + response.expiresAt + ), + role: response.user.role, + defaultCurrency: response.user.defaultCurrency, + countryCode: response.user.countryCode, + timezone: response.user.timezone, + hasCategories: response.user.hasCategories + }; } - } + }), + ...(googleSignInProvider === 'passport' + ? [ + Credentials({ + id: 'passport-code', + name: 'Passport', + credentials: { + code: { label: 'Code', type: 'text' }, + codeVerifier: { + label: 'Code verifier', + type: 'text' + } + }, + authorize: async credentials => { + const code = String(credentials?.code ?? ''); + const codeVerifier = String( + credentials?.codeVerifier ?? '' + ); + const response = + await apiClient().auth.passportExchange({ + body: { code, codeVerifier } + }); + + return { + id: String(response.user.id), + email: response.user.email, + apiToken: response.token, + apiTokenExpiresAt: apiTokenExpiresAt( + response.expiresAt + ), + role: response.user.role, + defaultCurrency: + response.user.defaultCurrency, + countryCode: response.user.countryCode, + timezone: response.user.timezone, + hasCategories: response.user.hasCategories + }; + } + }) + ] + : []), + ...(googleSignInProvider === 'direct' + ? [ + Google({ + clientId: webConfig.google.clientId ?? '', + clientSecret: webConfig.google.clientSecret ?? '' + }) + ] + : []), + Credentials({ + id: 'email-confirmation-token', + name: 'Email confirmation', + credentials: { + token: { label: 'Token', type: 'text' } + }, + authorize: async credentials => { + const token = String(credentials?.token ?? ''); + const response = await apiClient().auth.confirmEmail({ + body: { token } + }); - return token; + return { + id: String(response.user.id), + email: response.user.email, + apiToken: response.token, + apiTokenExpiresAt: apiTokenExpiresAt( + response.expiresAt + ), + role: response.user.role, + defaultCurrency: response.user.defaultCurrency, + countryCode: response.user.countryCode, + timezone: response.user.timezone, + hasCategories: response.user.hasCategories + }; + } + }) + ], + pages: { + signIn: '/login', + error: expiredSessionPath }, - async session({ session, token }) { - return { - ...session, - apiToken: token.apiToken ?? '', - user: { - ...session.user, - id: token.sub ?? '', - email: token.email ?? '', - role: token.role ?? 'user', - defaultCurrency: token.defaultCurrency ?? 'USD', - countryCode: token.countryCode ?? 'US', - timezone: token.timezone ?? 'UTC', - hasCategories: Boolean(token.hasCategories) + callbacks: { + async jwt({ token, user, account, profile }) { + if (account?.provider === 'google') { + const response = + await internalApiClient().auth.googleSignIn({ + body: googleSignInBodyFromAuthProfile( + account, + profile as Record | undefined + ) + }); + return applyTokenResponse(token, response); + } + + if (user?.apiToken) { + token.apiToken = user.apiToken; + token.apiTokenExpiresAt = user.apiTokenExpiresAt; + token.sub = user.id; + token.email = user.email; + token.role = user.role; + token.defaultCurrency = user.defaultCurrency; + token.countryCode = user.countryCode; + token.timezone = user.timezone; + token.hasCategories = user.hasCategories; + } + + if (shouldRefreshApiToken(token)) { + try { + return await refreshApiToken(token); + } catch (err) { + authLogger.warn(AuthWarningLogged, { + AuthWarningCode: + apiErrorStatus(err) === 401 + ? 'ApiTokenRefreshUnauthorized' + : 'ApiTokenRefreshFailed' + }); + if (apiErrorStatus(err) === 401) { + token.apiToken = undefined; + token.apiTokenExpiresAt = undefined; + } + } } - }; + + return token; + }, + async session({ session, token }) { + return { + ...session, + apiToken: token.apiToken ?? '', + user: { + ...session.user, + id: token.sub ?? '', + email: token.email ?? '', + role: token.role ?? 'user', + defaultCurrency: token.defaultCurrency ?? 'USD', + countryCode: token.countryCode ?? 'US', + timezone: token.timezone ?? 'UTC', + hasCategories: Boolean(token.hasCategories) + } + }; + } } - } -})); + }; +}); export const handlers: NextAuthResult['handlers'] = nextAuth.handlers; export const auth: NextAuthResult['auth'] = nextAuth.auth; diff --git a/apps/web/components/landing-page.tsx b/apps/web/components/landing-page.tsx index ab60e6d..8c8c6ff 100644 --- a/apps/web/components/landing-page.tsx +++ b/apps/web/components/landing-page.tsx @@ -94,7 +94,7 @@ const frameworkFeatures: readonly Feature[] = [ }, { description: - 'Passport sign-in, API keys, and protected endpoint metadata demonstrate framework-level auth integration.', + 'Google sign-in, API keys, and protected endpoint metadata demonstrate framework-level auth integration.', icon: ShieldCheckIcon, title: 'Auth-aware APIs' }, diff --git a/apps/web/lib/actions.ts b/apps/web/lib/actions.ts index 19ca84b..b65df52 100644 --- a/apps/web/lib/actions.ts +++ b/apps/web/lib/actions.ts @@ -19,7 +19,7 @@ import { getApiClient, getSessionOrRedirect } from './api'; -import { webConfig } from './config'; +import { getGoogleSignInProvider, webConfig } from './config'; import { VendorUpdateActionRejected } from './log-templates'; import { loggerFor } from './logger'; import { @@ -254,9 +254,16 @@ function pkceChallenge(verifier: string): string { } function passportLoginUrl(codeChallenge: string): string { - const url = new URL('/login', webConfig.passport.baseUrl); - url.searchParams.set('project', webConfig.passport.project); - url.searchParams.set('env', webConfig.passport.environment); + const { baseUrl, environment, project } = webConfig.passport; + if (!baseUrl || !environment || !project) { + throw new Error( + 'Passport sign-in requires PASSPORT_BASE_URL, PASSPORT_PROJECT, and PASSPORT_ENVIRONMENT.' + ); + } + + const url = new URL('/login', baseUrl); + url.searchParams.set('project', project); + url.searchParams.set('env', environment); url.searchParams.set('code_challenge', codeChallenge); url.searchParams.set('code_challenge_method', 'S256'); return url.toString(); @@ -342,6 +349,15 @@ export async function resendEmailConfirmationAction(formData: FormData) { } export async function googleSignInAction() { + const provider = getGoogleSignInProvider(); + if (provider === 'direct') { + await signIn('google', { redirectTo: '/dashboard' }); + return; + } + if (provider === 'disabled') { + redirect('/login'); + } + const verifier = randomBytes(32).toString('base64url'); const cookieStore = await cookies(); cookieStore.set(passportPkceCookie, verifier, { diff --git a/apps/web/lib/config.ts b/apps/web/lib/config.ts index 46ac8c5..6c80be0 100644 --- a/apps/web/lib/config.ts +++ b/apps/web/lib/config.ts @@ -1,5 +1,6 @@ import { env, parseEnv } from '@cleverbrush/env'; import { string } from '@cleverbrush/schema'; +import { GoogleSignInModes, resolveGoogleSignInProvider } from './google-auth'; export const webConfig = parseEnv({ nodeEnv: env('NODE_ENV', string().default('production')), @@ -18,16 +19,32 @@ export const webConfig = parseEnv({ ] as const) .default('information') ), + googleSignInMode: env( + 'GOOGLE_SIGN_IN_MODE', + string().oneOf(GoogleSignInModes).default('auto') + ), + google: { + clientId: env('AUTH_GOOGLE_ID', string().optional()), + clientSecret: env('AUTH_GOOGLE_SECRET', string().optional()) + }, passport: { - baseUrl: env( - 'PASSPORT_BASE_URL', - string().default('https://auth.cleverbrush.com') - ), - project: env('PASSPORT_PROJECT', string().default('xpenser')), - environment: env('PASSPORT_ENVIRONMENT', string().default('production')) + baseUrl: env('PASSPORT_BASE_URL', string().optional()), + project: env('PASSPORT_PROJECT', string().optional()), + environment: env('PASSPORT_ENVIRONMENT', string().optional()) } }); +export function getGoogleSignInProvider() { + return resolveGoogleSignInProvider({ + mode: webConfig.googleSignInMode, + googleClientId: webConfig.google.clientId, + googleClientSecret: webConfig.google.clientSecret, + passportBaseUrl: webConfig.passport.baseUrl, + passportProject: webConfig.passport.project, + passportEnvironment: webConfig.passport.environment + }); +} + const PLACEHOLDER_SECRET = 'change-me-in-production-min32chars'; export function getNextAuthSecret(): string { diff --git a/apps/web/lib/google-auth.test.ts b/apps/web/lib/google-auth.test.ts new file mode 100644 index 0000000..0cad1aa --- /dev/null +++ b/apps/web/lib/google-auth.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { + googleSignInBodyFromAuthProfile, + resolveGoogleSignInProvider +} from './google-auth'; + +describe('Google auth helpers', () => { + it('prefers direct Google auth in auto mode when Auth.js credentials exist', () => { + expect( + resolveGoogleSignInProvider({ + mode: 'auto', + googleClientId: 'google-id', + googleClientSecret: 'google-secret', + passportBaseUrl: 'https://auth.example.com', + passportProject: 'xpenser', + passportEnvironment: 'production' + }) + ).toBe('direct'); + }); + + it('falls back to Passport in auto mode when only Passport is configured', () => { + expect( + resolveGoogleSignInProvider({ + mode: 'auto', + passportBaseUrl: 'https://auth.example.com', + passportProject: 'xpenser', + passportEnvironment: 'production' + }) + ).toBe('passport'); + }); + + it('disables Google sign-in in auto mode without complete auth config', () => { + expect( + resolveGoogleSignInProvider({ + mode: 'auto', + googleClientId: 'google-id' + }) + ).toBe('disabled'); + }); + + it('rejects incomplete explicit direct and Passport modes', () => { + expect(() => + resolveGoogleSignInProvider({ + mode: 'direct', + googleClientId: 'google-id' + }) + ).toThrow('AUTH_GOOGLE_ID and AUTH_GOOGLE_SECRET'); + + expect(() => + resolveGoogleSignInProvider({ + mode: 'passport', + passportBaseUrl: 'https://auth.example.com' + }) + ).toThrow('PASSPORT_BASE_URL'); + }); + + it('maps Auth.js Google profile values to the API body', () => { + expect( + googleSignInBodyFromAuthProfile( + { providerAccountId: 'fallback-subject' }, + { + sub: 'google-subject', + email: ' jane@example.com ', + email_verified: true, + name: 'Jane Doe', + picture: 'https://example.com/avatar.png' + } + ) + ).toEqual({ + providerSubject: 'google-subject', + email: 'jane@example.com', + emailVerified: true, + name: 'Jane Doe', + avatarUrl: 'https://example.com/avatar.png' + }); + }); + + it('uses the account subject when the profile omits sub', () => { + expect( + googleSignInBodyFromAuthProfile( + { providerAccountId: 'account-subject' }, + { + email: 'jane@example.com', + email_verified: 'true' + } + ) + ).toMatchObject({ + providerSubject: 'account-subject', + emailVerified: true + }); + }); +}); diff --git a/apps/web/lib/google-auth.ts b/apps/web/lib/google-auth.ts new file mode 100644 index 0000000..23d876b --- /dev/null +++ b/apps/web/lib/google-auth.ts @@ -0,0 +1,108 @@ +import type { GoogleSignInBody } from '@xpenser/contracts'; + +export const GoogleSignInModes = [ + 'auto', + 'direct', + 'passport', + 'disabled' +] as const; + +export type GoogleSignInMode = (typeof GoogleSignInModes)[number]; +export type GoogleSignInProvider = 'direct' | 'passport' | 'disabled'; + +type GoogleSignInProviderOptions = { + readonly mode: GoogleSignInMode; + readonly googleClientId?: string; + readonly googleClientSecret?: string; + readonly passportBaseUrl?: string; + readonly passportProject?: string; + readonly passportEnvironment?: string; +}; + +type GoogleAuthAccount = { + readonly providerAccountId?: string | null; +}; + +type GoogleAuthProfile = Record | undefined; + +function present(value: string | undefined): boolean { + return typeof value === 'string' && value.trim() !== ''; +} + +export function resolveGoogleSignInProvider({ + mode, + googleClientId, + googleClientSecret, + passportBaseUrl, + passportEnvironment, + passportProject +}: GoogleSignInProviderOptions): GoogleSignInProvider { + const directConfigured = + present(googleClientId) && present(googleClientSecret); + const passportConfigured = + present(passportBaseUrl) && + present(passportProject) && + present(passportEnvironment); + + if (mode === 'disabled') { + return 'disabled'; + } + if (mode === 'direct') { + if (!directConfigured) { + throw new Error( + 'GOOGLE_SIGN_IN_MODE=direct requires AUTH_GOOGLE_ID and AUTH_GOOGLE_SECRET.' + ); + } + return 'direct'; + } + if (mode === 'passport') { + if (!passportConfigured) { + throw new Error( + 'GOOGLE_SIGN_IN_MODE=passport requires PASSPORT_BASE_URL, PASSPORT_PROJECT, and PASSPORT_ENVIRONMENT.' + ); + } + return 'passport'; + } + + if (directConfigured) { + return 'direct'; + } + if (passportConfigured) { + return 'passport'; + } + return 'disabled'; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' + ? value.trim() + : undefined; +} + +function booleanValue(value: unknown): boolean { + return value === true || value === 'true'; +} + +export function googleSignInBodyFromAuthProfile( + account: GoogleAuthAccount, + profile: GoogleAuthProfile +): GoogleSignInBody { + const providerSubject = + stringValue(profile?.sub) ?? stringValue(account.providerAccountId); + const email = stringValue(profile?.email); + + if (!providerSubject) { + throw new Error('Google profile subject is missing.'); + } + if (!email) { + throw new Error('Google profile email is missing.'); + } + + return { + providerSubject, + email, + emailVerified: booleanValue(profile?.email_verified), + name: stringValue(profile?.name), + avatarUrl: stringValue(profile?.picture) + }; +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a96a1c7..2c3289c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -36,9 +36,9 @@ services: JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required} JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-1209600} WEB_API_SERVICE_SECRET: ${WEB_API_SERVICE_SECRET:-${NEXTAUTH_SECRET:?NEXTAUTH_SECRET required}} - PASSPORT_BASE_URL: ${PASSPORT_BASE_URL:-https://auth.cleverbrush.com} - PASSPORT_PROJECT: ${PASSPORT_PROJECT:-xpenser} - PASSPORT_ENVIRONMENT: ${PASSPORT_ENVIRONMENT:-production} + PASSPORT_BASE_URL: ${PASSPORT_BASE_URL:-} + PASSPORT_PROJECT: ${PASSPORT_PROJECT:-} + PASSPORT_ENVIRONMENT: ${PASSPORT_ENVIRONMENT:-} PASSPORT_PUBLIC_KEY: ${PASSPORT_PUBLIC_KEY:-} TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-} TELEGRAM_BOT_SERVICE_SECRET: ${TELEGRAM_BOT_SERVICE_SECRET:?TELEGRAM_BOT_SERVICE_SECRET required} @@ -84,9 +84,12 @@ services: AUTH_SECRET: ${AUTH_SECRET:-${NEXTAUTH_SECRET:?NEXTAUTH_SECRET required}} WEB_API_SERVICE_SECRET: ${WEB_API_SERVICE_SECRET:-${NEXTAUTH_SECRET:?NEXTAUTH_SECRET required}} API_BASE_URL: http://api:4000 - PASSPORT_BASE_URL: ${PASSPORT_BASE_URL:-https://auth.cleverbrush.com} - PASSPORT_PROJECT: ${PASSPORT_PROJECT:-xpenser} - PASSPORT_ENVIRONMENT: ${PASSPORT_ENVIRONMENT:-production} + GOOGLE_SIGN_IN_MODE: ${GOOGLE_SIGN_IN_MODE:-auto} + AUTH_GOOGLE_ID: ${AUTH_GOOGLE_ID:-} + AUTH_GOOGLE_SECRET: ${AUTH_GOOGLE_SECRET:-} + PASSPORT_BASE_URL: ${PASSPORT_BASE_URL:-} + PASSPORT_PROJECT: ${PASSPORT_PROJECT:-} + PASSPORT_ENVIRONMENT: ${PASSPORT_ENVIRONMENT:-} WEB_OTEL_SERVICE_NAME: ${OTEL_WEB_SERVICE_NAME:-xpenser-web} OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4318} ports: diff --git a/docker-compose.yml b/docker-compose.yml index 544b64a..c5e1799 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,9 +38,9 @@ services: JWT_SECRET: ${JWT_SECRET:?JWT_SECRET required} JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-1209600} WEB_API_SERVICE_SECRET: ${WEB_API_SERVICE_SECRET:-${NEXTAUTH_SECRET:?NEXTAUTH_SECRET required}} - PASSPORT_BASE_URL: ${PASSPORT_BASE_URL:-https://auth.cleverbrush.com} - PASSPORT_PROJECT: ${PASSPORT_PROJECT:-xpenser} - PASSPORT_ENVIRONMENT: ${PASSPORT_ENVIRONMENT:-production} + PASSPORT_BASE_URL: ${PASSPORT_BASE_URL:-} + PASSPORT_PROJECT: ${PASSPORT_PROJECT:-} + PASSPORT_ENVIRONMENT: ${PASSPORT_ENVIRONMENT:-} PASSPORT_PUBLIC_KEY: ${PASSPORT_PUBLIC_KEY:-} TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-} TELEGRAM_BOT_SERVICE_SECRET: ${TELEGRAM_BOT_SERVICE_SECRET:?TELEGRAM_BOT_SERVICE_SECRET required} @@ -86,9 +86,12 @@ services: AUTH_SECRET: ${AUTH_SECRET:-${NEXTAUTH_SECRET:?NEXTAUTH_SECRET required}} WEB_API_SERVICE_SECRET: ${WEB_API_SERVICE_SECRET:-${NEXTAUTH_SECRET:?NEXTAUTH_SECRET required}} API_BASE_URL: http://api:4000 - PASSPORT_BASE_URL: ${PASSPORT_BASE_URL:-https://auth.cleverbrush.com} - PASSPORT_PROJECT: ${PASSPORT_PROJECT:-xpenser} - PASSPORT_ENVIRONMENT: ${PASSPORT_ENVIRONMENT:-production} + GOOGLE_SIGN_IN_MODE: ${GOOGLE_SIGN_IN_MODE:-auto} + AUTH_GOOGLE_ID: ${AUTH_GOOGLE_ID:-} + AUTH_GOOGLE_SECRET: ${AUTH_GOOGLE_SECRET:-} + PASSPORT_BASE_URL: ${PASSPORT_BASE_URL:-} + PASSPORT_PROJECT: ${PASSPORT_PROJECT:-} + PASSPORT_ENVIRONMENT: ${PASSPORT_ENVIRONMENT:-} WEB_OTEL_SERVICE_NAME: xpenser-web OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318 ports: diff --git a/packages/contracts/src/api.test.ts b/packages/contracts/src/api.test.ts index 894d960..b5de659 100644 --- a/packages/contracts/src/api.test.ts +++ b/packages/contracts/src/api.test.ts @@ -15,6 +15,7 @@ describe('api contract authorization metadata', () => { expect(authRoles(api.auth.resendEmailConfirmation)).toBeNull(); expect(authRoles(api.auth.passportResolveUser)).toBeNull(); expect(authRoles(api.auth.passportExchange)).toBeNull(); + expect(authRoles(api.auth.googleSignIn)).toBeNull(); expect(authRoles(api.currencies.list)).toBeNull(); expect(authRoles(api.telegram.link)).toBeNull(); expect(authRoles(api.telegram.token)).toBeNull(); diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index 7339b52..04cdfa9 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -23,6 +23,7 @@ import { EmailConfirmationMessageResponseSchema, EmailConfirmationPendingResponseSchema, ErrorResponseSchema, + GoogleSignInBodySchema, LinkTelegramAccountBodySchema, LinkTelegramAccountResponseSchema, LoginBodySchema, @@ -144,6 +145,14 @@ export const api = defineApi({ 400: ErrorResponseSchema, 401: ErrorResponseSchema }), + googleSignIn: endpoint + .post('/api/auth/google/sign-in') + .body(GoogleSignInBodySchema) + .responses({ + 200: TokenResponseSchema, + 400: ErrorResponseSchema, + 401: ErrorResponseSchema + }), sessionToken: endpoint .post('/api/auth/session-token') .body(SessionTokenBodySchema) diff --git a/packages/contracts/src/schemas.test.ts b/packages/contracts/src/schemas.test.ts index 6add3d3..7f42fc5 100644 --- a/packages/contracts/src/schemas.test.ts +++ b/packages/contracts/src/schemas.test.ts @@ -14,6 +14,7 @@ import { DashboardSummarySchema, DashboardWindowQuerySchema, EmailConfirmationPendingResponseSchema, + GoogleSignInBodySchema, LinkTelegramAccountBodySchema, LoginBodySchema, MoveAndDeleteCategoryBodySchema, @@ -458,6 +459,32 @@ describe('shared schemas', () => { ).toBe(false); }); + it('validates direct Google sign-in payloads', () => { + expect( + GoogleSignInBodySchema.validate({ + providerSubject: 'google-subject', + email: 'jane@example.com', + emailVerified: true, + name: 'Jane Doe', + avatarUrl: 'https://example.com/avatar.png' + }).valid + ).toBe(true); + expect( + GoogleSignInBodySchema.validate({ + providerSubject: '', + email: 'jane@example.com', + emailVerified: true + }).valid + ).toBe(false); + expect( + GoogleSignInBodySchema.validate({ + providerSubject: 'google-subject', + email: 'not-an-email', + emailVerified: true + }).valid + ).toBe(false); + }); + it('rejects mismatched registration passwords', () => { const result = RegisterBodySchema.validate({ email: 'jane@example.com', diff --git a/packages/contracts/src/schemas.ts b/packages/contracts/src/schemas.ts index 41c1982..5698c38 100644 --- a/packages/contracts/src/schemas.ts +++ b/packages/contracts/src/schemas.ts @@ -329,6 +329,36 @@ export const PassportExchangeBodySchema = object({ ) }).schemaName('PassportExchangeBody'); +export const GoogleSignInBodySchema = object({ + /** Stable Google account subject returned by Auth.js. */ + providerSubject: string() + .required('provider subject is required') + .nonempty('provider subject is required') + .maxLength(FieldLimits.passportSubject, 'provider subject is too long') + .describe('Stable Google account subject returned by Auth.js.'), + /** Verified email address returned by Google. */ + email: string() + .required('email is required') + .nonempty('email is required') + .maxLength(FieldLimits.email, 'email is too long') + .email('must be a valid email address') + .describe('Verified email address returned by Google.'), + /** Whether Google verified the email address. */ + emailVerified: boolean() + .required('email verification is required') + .describe('Whether Google verified the email address.'), + /** Display name returned by Google. */ + name: string() + .optional() + .maxLength(FieldLimits.passportDisplayName, 'display name is too long') + .describe('Display name returned by Google.'), + /** Avatar URL returned by Google. */ + avatarUrl: string() + .optional() + .maxLength(FieldLimits.passportAvatarUrl, 'avatar URL is too long') + .describe('Avatar URL returned by Google.') +}).schemaName('GoogleSignInBody'); + export const SessionTokenBodySchema = object({ /** Authenticated user identifier stored in the trusted web session. */ userId: number().describe( @@ -2082,6 +2112,7 @@ export type PassportResolveUserResponse = InferType< typeof PassportResolveUserResponseSchema >; export type PassportExchangeBody = InferType; +export type GoogleSignInBody = InferType; export type TokenResponse = InferType; export type UserPreference = InferType; export type ApiKey = InferType; diff --git a/pr-env.sh b/pr-env.sh index e16b7b4..5b580e1 100755 --- a/pr-env.sh +++ b/pr-env.sh @@ -174,6 +174,9 @@ WEB_API_SERVICE_SECRET=${WEB_API_SERVICE_SECRET} NEXTAUTH_URL=https://${DOMAIN} NEXTAUTH_SECRET=${NEXTAUTH_SECRET} AUTH_SECRET=${NEXTAUTH_SECRET} +GOOGLE_SIGN_IN_MODE=passport +AUTH_GOOGLE_ID= +AUTH_GOOGLE_SECRET= PASSPORT_BASE_URL=${PASSPORT_BASE_URL} PASSPORT_PROJECT=${PASSPORT_PROJECT} PASSPORT_ENVIRONMENT=${PASSPORT_ENVIRONMENT}