diff --git a/apps/payments/api/.env b/apps/payments/api/.env index 94a2ecb386a..df7b378fff4 100644 --- a/apps/payments/api/.env +++ b/apps/payments/api/.env @@ -62,3 +62,9 @@ GLEAN_CONFIG__LOGGER_APP_NAME='fxa-payments-next' FXA_WEBHOOK_CONFIG__FXA_WEBHOOK_ISSUER=https://accounts.firefox.com/ FXA_WEBHOOK_CONFIG__FXA_WEBHOOK_JWKS_URI=https://oauth.accounts.firefox.com/v1/jwks/ FXA_WEBHOOK_CONFIG__FXA_WEBHOOK_AUDIENCE= + +# FXA OAuth Config +FXA_O_AUTH_CONFIG__FXA_O_AUTH_JWKS_URI=http://localhost:9000/v1/jwks +FXA_O_AUTH_CONFIG__FXA_O_AUTH_ISSUER=http://localhost:3030 +FXA_O_AUTH_CONFIG__FXA_O_AUTH_REQUIRED_SCOPE=https://identity.mozilla.com/account/subscriptions +FXA_O_AUTH_CONFIG__FXA_O_AUTH_SERVER_URL=http://localhost:9000 diff --git a/apps/payments/api/src/app/app.module.ts b/apps/payments/api/src/app/app.module.ts index 14e6e3fb25c..97a7aa01fc6 100644 --- a/apps/payments/api/src/app/app.module.ts +++ b/apps/payments/api/src/app/app.module.ts @@ -3,6 +3,7 @@ import { TypedConfigModule, dotenvLoader } from 'nest-typed-config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { RootConfig } from '../config'; +import { AuthModule } from '@fxa/payments/auth'; import { CmsWebhooksController, CmsWebhookService, @@ -46,6 +47,7 @@ import { NimbusClient, NimbusClientConfig } from '@fxa/shared/experiments'; @Module({ imports: [ + AuthModule, TypedConfigModule.forRoot({ schema: RootConfig, load: dotenvLoader({ diff --git a/apps/payments/api/src/config/index.ts b/apps/payments/api/src/config/index.ts index c76eecc430c..30cb3e21278 100644 --- a/apps/payments/api/src/config/index.ts +++ b/apps/payments/api/src/config/index.ts @@ -10,6 +10,7 @@ import { MySQLConfig } from '@fxa/shared/db/mysql/core'; import { FxaWebhookConfig, StripeEventConfig } from '@fxa/payments/webhooks'; import { StatsDConfig } from '@fxa/shared/metrics/statsd'; import { FirestoreConfig } from 'libs/shared/db/firestore/src/lib/firestore.config'; +import { FxaOAuthConfig } from '@fxa/payments/auth'; export class RootConfig { @Type(() => MySQLConfig) @@ -61,4 +62,9 @@ export class RootConfig { @ValidateNested() @IsDefined() public readonly fxaWebhookConfig!: Partial; + + @Type(() => FxaOAuthConfig) + @ValidateNested() + @IsDefined() + public readonly fxaOAuthConfig!: Partial; } diff --git a/libs/payments/auth/.eslintrc.json b/libs/payments/auth/.eslintrc.json new file mode 100644 index 00000000000..3456be9b903 --- /dev/null +++ b/libs/payments/auth/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/payments/auth/jest.config.ts b/libs/payments/auth/jest.config.ts new file mode 100644 index 00000000000..801e873f3aa --- /dev/null +++ b/libs/payments/auth/jest.config.ts @@ -0,0 +1,10 @@ +export default { + displayName: 'payments-auth', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/libs/payments/auth', +}; diff --git a/libs/payments/auth/package.json b/libs/payments/auth/package.json new file mode 100644 index 00000000000..4b4a792c7a1 --- /dev/null +++ b/libs/payments/auth/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fxa/payments/auth", + "version": "0.0.1" +} diff --git a/libs/payments/auth/project.json b/libs/payments/auth/project.json new file mode 100644 index 00000000000..25c46831fd1 --- /dev/null +++ b/libs/payments/auth/project.json @@ -0,0 +1,27 @@ +{ + "name": "payments-auth", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/payments/auth/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/esbuild:esbuild", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/payments/auth", + "main": "libs/payments/auth/src/index.ts", + "tsConfig": "libs/payments/auth/tsconfig.lib.json", + "assets": ["libs/payments/auth/*.md"], + "format": ["cjs"] + } + }, + "test-unit": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/payments/auth/jest.config.ts" + } + } + } +} diff --git a/libs/payments/auth/src/index.ts b/libs/payments/auth/src/index.ts new file mode 100644 index 00000000000..949c25347ec --- /dev/null +++ b/libs/payments/auth/src/index.ts @@ -0,0 +1,8 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export * from './lib/auth.module'; +export * from './lib/fxa-oauth-auth.guard'; +export * from './lib/fxa-access-token.schemas'; +export * from './lib/fxa-oauth.config'; diff --git a/libs/payments/auth/src/lib/auth.module.ts b/libs/payments/auth/src/lib/auth.module.ts new file mode 100644 index 00000000000..96a7a183102 --- /dev/null +++ b/libs/payments/auth/src/lib/auth.module.ts @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Module } from '@nestjs/common'; + +import { FxaOAuthJwtStrategy } from './fxa-oauth-jwt.strategy'; +import { FxaOAuthVerifyStrategy } from './fxa-oauth-verify.strategy'; + +@Module({ + providers: [FxaOAuthJwtStrategy, FxaOAuthVerifyStrategy], + exports: [FxaOAuthJwtStrategy, FxaOAuthVerifyStrategy], +}) +export class AuthModule {} diff --git a/libs/payments/auth/src/lib/factories/fxa-access-token-claims.factory.ts b/libs/payments/auth/src/lib/factories/fxa-access-token-claims.factory.ts new file mode 100644 index 00000000000..c320c2acf80 --- /dev/null +++ b/libs/payments/auth/src/lib/factories/fxa-access-token-claims.factory.ts @@ -0,0 +1,15 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { faker } from '@faker-js/faker'; +import { FxaAccessTokenClaims } from '../fxa-access-token.schemas'; + +export const FxaAccessTokenClaimsFactory = ( + override?: Partial +): FxaAccessTokenClaims => ({ + sub: faker.string.hexadecimal({ length: 32, prefix: '' }), + client_id: faker.string.hexadecimal({ length: 16, prefix: '' }), + scope: 'profile', + ...override, +}); diff --git a/libs/payments/auth/src/lib/factories/verify-response.factory.ts b/libs/payments/auth/src/lib/factories/verify-response.factory.ts new file mode 100644 index 00000000000..24bbf6d4f5c --- /dev/null +++ b/libs/payments/auth/src/lib/factories/verify-response.factory.ts @@ -0,0 +1,15 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { faker } from '@faker-js/faker'; +import { FxaVerifyResponse } from '../fxa-access-token.schemas'; + +export const FxaVerifyResponseFactory = ( + override?: Partial +): FxaVerifyResponse => ({ + user: faker.string.hexadecimal({ length: 32, prefix: '' }), + client_id: faker.string.hexadecimal({ length: 16, prefix: '' }), + scope: ['profile'], + ...override, +}); diff --git a/libs/payments/auth/src/lib/fxa-access-token.schemas.ts b/libs/payments/auth/src/lib/fxa-access-token.schemas.ts new file mode 100644 index 00000000000..29b0fc3bdcb --- /dev/null +++ b/libs/payments/auth/src/lib/fxa-access-token.schemas.ts @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { z } from 'zod'; + +/** + * Common user identity returned by both FxA OAuth strategies. + * Contains only the fields reliably available from both JWT and + * opaque-token verification paths. + */ +export const fxaOAuthUserSchema = z.object({ + sub: z.string(), + client_id: z.string(), + scope: z.array(z.string()), +}); + +export type FxaOAuthUser = z.infer; + +/** + * Zod schema for JWT claims from an FxA OAuth access token. + */ +export const fxaAccessTokenClaimsSchema = z.object({ + sub: z.string(), + client_id: z.string(), + scope: z.string(), + 'fxa-generation': z.number().optional(), + 'fxa-profileChangedAt': z.number().optional(), +}); + +/** + * JWT claims from an FXA OAuth access token. + */ +export type FxaAccessTokenClaims = z.infer; + +/** + * Zod schema for the response from the FxA auth server's POST /v1/verify endpoint. + */ +export const fxaVerifyResponseSchema = z.object({ + user: z.string(), + client_id: z.string(), + scope: z.array(z.string()), + generation: z.number().optional(), + profile_changed_at: z.number().optional(), +}); + +/** + * Response from the FxA auth server's POST /v1/verify endpoint. + */ +export type FxaVerifyResponse = z.infer; diff --git a/libs/payments/auth/src/lib/fxa-oauth-auth.guard.spec.ts b/libs/payments/auth/src/lib/fxa-oauth-auth.guard.spec.ts new file mode 100644 index 00000000000..095c230a0ce --- /dev/null +++ b/libs/payments/auth/src/lib/fxa-oauth-auth.guard.spec.ts @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { FxaOAuthAuthGuard } from './fxa-oauth-auth.guard'; + +describe('FxaOAuthAuthGuard', () => { + it('can be instantiated', () => { + const guard = new FxaOAuthAuthGuard(); + expect(guard).toBeDefined(); + }); +}); diff --git a/libs/payments/auth/src/lib/fxa-oauth-auth.guard.ts b/libs/payments/auth/src/lib/fxa-oauth-auth.guard.ts new file mode 100644 index 00000000000..231b6d298c0 --- /dev/null +++ b/libs/payments/auth/src/lib/fxa-oauth-auth.guard.ts @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +/** + * Route guard that validates FxA OAuth access tokens. + * + * Accepts both token formats via Authorization: Bearer : + * + * 1. JWT access tokens — validated locally via JWKS (fast, no network call). + * Requires the OAuth client to be in jwtAccessTokens.enabledClientIds. + * See FxaOAuthJwtStrategy (./fxa-oauth-jwt.strategy.ts). + * + * 2. Opaque hex access tokens — validated by calling POST {auth-server}/v1/verify, + * the same path used by the profile server and the auth server's own + * oauthToken scheme. See FxaOAuthVerifyStrategy (./fxa-oauth-verify.strategy.ts). + * + * The JWT strategy is tried first. If it fails (token is not a JWT), the + * verify strategy handles it as a fallback. + * + * Requirements: + * - Token must include the scope from FXA_O_AUTH_CONFIG__FXA_O_AUTH_REQUIRED_SCOPE. + * - For JWTs: issuer must match oauthServer.openid.issuer (localhost:3030 in dev). + * - Auth server URL set via FXA_O_AUTH_CONFIG__FXA_O_AUTH_SERVER_URL for verify fallback. + */ +@Injectable() +export class FxaOAuthAuthGuard extends AuthGuard([ + 'fxa-oauth-jwt', + 'fxa-oauth-verify', +]) {} diff --git a/libs/payments/auth/src/lib/fxa-oauth-jwt.strategy.spec.ts b/libs/payments/auth/src/lib/fxa-oauth-jwt.strategy.spec.ts new file mode 100644 index 00000000000..2206800a5eb --- /dev/null +++ b/libs/payments/auth/src/lib/fxa-oauth-jwt.strategy.spec.ts @@ -0,0 +1,73 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { UnauthorizedException } from '@nestjs/common'; +import { FxaOAuthJwtStrategy } from './fxa-oauth-jwt.strategy'; +import { MockFxaOAuthConfig } from './fxa-oauth.config'; +import { FxaAccessTokenClaimsFactory } from './factories/fxa-access-token-claims.factory'; + +// Mock jwks-rsa to avoid real JWKS fetching during construction +jest.mock('jwks-rsa', () => ({ + passportJwtSecret: jest.fn(() => (_req: any, _raw: any, done: any) => + done(null, 'mock-secret') + ), +})); + +/** Build a mock Express Request. */ +function makeReq(): any { + return { headers: {} }; +} + +describe('FxaOAuthJwtStrategy', () => { + let strategy: FxaOAuthJwtStrategy; + + beforeEach(() => { + strategy = new FxaOAuthJwtStrategy(MockFxaOAuthConfig); + }); + + it('returns user with scope array when required scope is present', () => { + const claims = FxaAccessTokenClaimsFactory({ + scope: MockFxaOAuthConfig.fxaOAuthRequiredScope, + }); + const result = strategy.validate(makeReq(), claims); + expect(result).toEqual({ + sub: claims.sub, + client_id: claims.client_id, + scope: [MockFxaOAuthConfig.fxaOAuthRequiredScope], + }); + }); + + it('returns user with scope array when required scope is among multiple scopes', () => { + const claims = FxaAccessTokenClaimsFactory({ + scope: `profile ${MockFxaOAuthConfig.fxaOAuthRequiredScope} openid`, + }); + const result = strategy.validate(makeReq(), claims); + expect(result).toEqual({ + sub: claims.sub, + client_id: claims.client_id, + scope: ['profile', MockFxaOAuthConfig.fxaOAuthRequiredScope, 'openid'], + }); + }); + + it('throws UnauthorizedException when required scope is missing', () => { + const claims = FxaAccessTokenClaimsFactory({ scope: 'profile openid' }); + expect(() => strategy.validate(makeReq(), claims)).toThrow( + UnauthorizedException + ); + }); + + it('throws UnauthorizedException when scope is empty', () => { + const claims = FxaAccessTokenClaimsFactory({ scope: '' }); + expect(() => strategy.validate(makeReq(), claims)).toThrow( + UnauthorizedException + ); + }); + + it('throws UnauthorizedException when scope is undefined', () => { + const claims = FxaAccessTokenClaimsFactory({ scope: undefined } as any); + expect(() => strategy.validate(makeReq(), claims)).toThrow( + UnauthorizedException + ); + }); +}); diff --git a/libs/payments/auth/src/lib/fxa-oauth-jwt.strategy.ts b/libs/payments/auth/src/lib/fxa-oauth-jwt.strategy.ts new file mode 100644 index 00000000000..ce97c51da34 --- /dev/null +++ b/libs/payments/auth/src/lib/fxa-oauth-jwt.strategy.ts @@ -0,0 +1,57 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { passportJwtSecret } from 'jwks-rsa'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { Request } from 'express'; + +import { FxaOAuthConfig } from './fxa-oauth.config'; +import { + fxaAccessTokenClaimsSchema, + FxaOAuthUser, +} from './fxa-access-token.schemas'; + +@Injectable() +export class FxaOAuthJwtStrategy extends PassportStrategy( + Strategy, + 'fxa-oauth-jwt' +) { + private requiredScope: string; + + constructor(config: FxaOAuthConfig) { + super({ + secretOrKeyProvider: passportJwtSecret({ + cache: true, + rateLimit: true, + jwksRequestsPerMinute: 5, + jwksUri: config.fxaOAuthJwksUri, + }), + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + issuer: config.fxaOAuthIssuer, + algorithms: ['RS256'], + passReqToCallback: true, + }); + this.requiredScope = config.fxaOAuthRequiredScope; + } + + public validate(req: Request, claims: unknown): FxaOAuthUser { + const result = fxaAccessTokenClaimsSchema.safeParse(claims); + if (!result.success) { + throw new UnauthorizedException('Invalid token claims'); + } + + const scopes = result.data.scope?.split(' ') ?? []; + if (!scopes.includes(this.requiredScope)) { + throw new UnauthorizedException('Insufficient scope'); + } + + return { + sub: result.data.sub, + client_id: result.data.client_id, + scope: scopes, + }; + } +} diff --git a/libs/payments/auth/src/lib/fxa-oauth-verify.strategy.spec.ts b/libs/payments/auth/src/lib/fxa-oauth-verify.strategy.spec.ts new file mode 100644 index 00000000000..a68d24c6efb --- /dev/null +++ b/libs/payments/auth/src/lib/fxa-oauth-verify.strategy.spec.ts @@ -0,0 +1,118 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { FxaOAuthVerifyStrategy } from './fxa-oauth-verify.strategy'; +import { MockFxaOAuthConfig } from './fxa-oauth.config'; +import { FxaVerifyResponseFactory } from './factories/verify-response.factory'; + +const mockConfig = { + ...MockFxaOAuthConfig, + fxaOAuthServerUrl: 'http://localhost:9000', +}; + +function makeReq(token?: string) { + return { + headers: { + authorization: token ? `Bearer ${token}` : undefined, + }, + } as any; +} + +describe('FxaOAuthVerifyStrategy', () => { + let strategy: FxaOAuthVerifyStrategy; + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + strategy = new FxaOAuthVerifyStrategy(mockConfig); + fetchSpy = jest.spyOn(global, 'fetch'); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('returns mapped claims for a valid token with required scope', async () => { + const body = FxaVerifyResponseFactory({ + scope: [MockFxaOAuthConfig.fxaOAuthRequiredScope], + }); + fetchSpy.mockResolvedValue({ + ok: true, + json: async () => body, + }); + + const result = await new Promise((resolve, reject) => { + (strategy as any)._verify(makeReq('valid-token'), (err: any, user: any) => { + if (err) reject(err); + else resolve(user); + }); + }); + + expect(fetchSpy).toHaveBeenCalledWith( + 'http://localhost:9000/v1/verify', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ token: 'valid-token' }), + }) + ); + expect(result).toEqual({ + sub: body.user, + client_id: body.client_id, + scope: body.scope, + }); + }); + + it('returns false when required scope is missing', async () => { + fetchSpy.mockResolvedValue({ + ok: true, + json: async () => FxaVerifyResponseFactory({ scope: ['profile'] }), + }); + + const result = await new Promise((resolve) => { + (strategy as any)._verify(makeReq('token'), (_err: any, user: any) => { + resolve(user); + }); + }); + + expect(result).toBe(false); + }); + + it('returns false when auth server rejects the token', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ message: 'Invalid token' }), + }); + + const result = await new Promise((resolve) => { + (strategy as any)._verify(makeReq('bad-token'), (_err: any, user: any) => { + resolve(user); + }); + }); + + expect(result).toBe(false); + }); + + it('returns false when no Bearer token is provided', async () => { + const result = await new Promise((resolve) => { + (strategy as any)._verify(makeReq(), (_err: any, user: any) => { + resolve(user); + }); + }); + + expect(result).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('returns false when auth server is unreachable', async () => { + fetchSpy.mockRejectedValue(new Error('ECONNREFUSED')); + + const result = await new Promise((resolve) => { + (strategy as any)._verify(makeReq('token'), (_err: any, user: any) => { + resolve(user); + }); + }); + + expect(result).toBe(false); + }); +}); diff --git a/libs/payments/auth/src/lib/fxa-oauth-verify.strategy.ts b/libs/payments/auth/src/lib/fxa-oauth-verify.strategy.ts new file mode 100644 index 00000000000..9387ec16af3 --- /dev/null +++ b/libs/payments/auth/src/lib/fxa-oauth-verify.strategy.ts @@ -0,0 +1,86 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy } from 'passport-custom'; +import { Request } from 'express'; + +import { FxaOAuthConfig } from './fxa-oauth.config'; +import { + FxaOAuthUser, + fxaVerifyResponseSchema, +} from './fxa-access-token.schemas'; + +/** + * Passport strategy that validates opaque FxA OAuth access tokens by calling + * the auth server's POST /v1/verify endpoint. This is the same verification + * path used by the profile server and the auth server's own oauthToken scheme. + * + * Used as a fallback when the token is not a JWT (see FxaOAuthJwtStrategy). + */ +@Injectable() +export class FxaOAuthVerifyStrategy extends PassportStrategy( + Strategy, + 'fxa-oauth-verify' +) { + private verifyUrl: string; + private requiredScope: string; + + constructor(config: FxaOAuthConfig) { + super( + async (req: Request, done: (err: Error | null, user?: any) => void) => { + try { + const claims = await this.verifyToken(req); + done(null, claims); + } catch (err) { + done(null, false); + } + } + ); + this.verifyUrl = `${config.fxaOAuthServerUrl}/v1/verify`; + this.requiredScope = config.fxaOAuthRequiredScope; + } + + private async verifyToken(req: Request): Promise { + const token = this.extractBearerToken(req); + if (!token) { + throw new UnauthorizedException('Bearer token not provided'); + } + + const res = await fetch(this.verifyUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + + if (!res.ok) { + throw new UnauthorizedException('Bearer token invalid'); + } + + const verifyResult = fxaVerifyResponseSchema.safeParse(await res.json()); + if (!verifyResult.success) { + throw new UnauthorizedException('Invalid verify response'); + } + const body = verifyResult.data; + + if (!body.scope?.includes(this.requiredScope)) { + throw new UnauthorizedException('Insufficient scope'); + } + + return { + sub: body.user, + client_id: body.client_id, + scope: body.scope, + }; + } + + private extractBearerToken(req: Request): string | null { + const auth = req.headers.authorization; + if (!auth?.startsWith('Bearer ')) { + return null; + } + return auth.slice(7); + } +} diff --git a/libs/payments/auth/src/lib/fxa-oauth.config.ts b/libs/payments/auth/src/lib/fxa-oauth.config.ts new file mode 100644 index 00000000000..8a608f97626 --- /dev/null +++ b/libs/payments/auth/src/lib/fxa-oauth.config.ts @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { faker } from '@faker-js/faker'; +import { Provider } from '@nestjs/common'; +import { IsString } from 'class-validator'; + +export class FxaOAuthConfig { + @IsString() + public readonly fxaOAuthJwksUri!: string; + + @IsString() + public readonly fxaOAuthIssuer!: string; + + @IsString() + public readonly fxaOAuthRequiredScope!: string; + + @IsString() + public readonly fxaOAuthServerUrl!: string; +} + +export const MockFxaOAuthConfig = { + fxaOAuthJwksUri: faker.internet.url(), + fxaOAuthIssuer: faker.internet.url(), + fxaOAuthRequiredScope: + 'https://identity.mozilla.com/account/subscriptions', + fxaOAuthServerUrl: faker.internet.url(), +} satisfies FxaOAuthConfig; + +export const MockFxaOAuthConfigProvider = { + provide: FxaOAuthConfig, + useValue: MockFxaOAuthConfig, +} satisfies Provider; diff --git a/libs/payments/auth/tsconfig.json b/libs/payments/auth/tsconfig.json new file mode 100644 index 00000000000..25f7201d870 --- /dev/null +++ b/libs/payments/auth/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/payments/auth/tsconfig.lib.json b/libs/payments/auth/tsconfig.lib.json new file mode 100644 index 00000000000..4befa7f0990 --- /dev/null +++ b/libs/payments/auth/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/payments/auth/tsconfig.spec.json b/libs/payments/auth/tsconfig.spec.json new file mode 100644 index 00000000000..ab55b7c7acb --- /dev/null +++ b/libs/payments/auth/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/package.json b/package.json index b9608850f97..5695c8373a5 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "jose": "^5.9.6", "jsdom": "^26.0.0", "jsonwebtoken": "^9.0.3", + "jwks-rsa": "^3.1.0", "knex": "^3.1.0", "kysely": "^0.28.14", "lint-staged": "^15.2.0", @@ -128,6 +129,7 @@ "objection": "^3.1.3", "os-browserify": "^0.3.0", "passport": "^0.7.0", + "passport-custom": "^1.1.1", "passport-http-bearer": "^1.0.1", "passport-jwt": "^4.0.1", "path-browserify": "^1.0.1", diff --git a/tsconfig.base.json b/tsconfig.base.json index 87ca15cc8b4..4dcca85dd24 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -37,6 +37,7 @@ ], "@fxa/accounts/two-factor": ["libs/accounts/two-factor/src/index.ts"], "@fxa/google": ["libs/google/src/index.ts"], + "@fxa/payments/auth": ["libs/payments/auth/src/index.ts"], "@fxa/payments/capability": ["libs/payments/capability/src/index.ts"], "@fxa/payments/cart": ["libs/payments/cart/src/index.ts"], "@fxa/payments/content-server": [ diff --git a/yarn.lock b/yarn.lock index 97803495178..b1880ade563 100644 --- a/yarn.lock +++ b/yarn.lock @@ -32777,6 +32777,7 @@ __metadata: json: "npm:^11.0.0" jsonc-eslint-parser: "npm:^2.1.0" jsonwebtoken: "npm:^9.0.3" + jwks-rsa: "npm:^3.1.0" knex: "npm:^3.1.0" kysely: "npm:^0.28.14" lint-staged: "npm:^15.2.0" @@ -32802,6 +32803,7 @@ __metadata: objection: "npm:^3.1.3" os-browserify: "npm:^0.3.0" passport: "npm:^0.7.0" + passport-custom: "npm:^1.1.1" passport-http-bearer: "npm:^1.0.1" passport-jwt: "npm:^4.0.1" path-browserify: "npm:^1.0.1" @@ -43889,6 +43891,15 @@ __metadata: languageName: node linkType: hard +"passport-custom@npm:^1.1.1": + version: 1.1.1 + resolution: "passport-custom@npm:1.1.1" + dependencies: + passport-strategy: "npm:1.x.x" + checksum: 10c0/49b6fcd125dcd60272d4f02c27acb3b61b2659f3148bc10b31b7c439314054ce32c83a12f422215bdfa83d0463668a1f38ca6e8d68ccd32c922f73ccaa5ac9b3 + languageName: node + linkType: hard + "passport-http-bearer@npm:^1.0.1": version: 1.0.1 resolution: "passport-http-bearer@npm:1.0.1"