diff --git a/server/src/auth/strategies/oauth2.strategy.spec.ignore.ts b/server/src/auth/strategies/oauth2.strategy.spec.ignore.ts deleted file mode 100644 index c982dcc93..000000000 --- a/server/src/auth/strategies/oauth2.strategy.spec.ignore.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Oauth2Strategy } from './oauth2.strategy'; -//import { Strategy } from 'passport-oauth2'; -//import * as passportOauth2 from 'passport-oauth2'; - -//jest.spyOn(Strategy, 'Strategy'); -jest.mock('passport-oauth2', () => ({ - Strategy: jest.fn().mockImplementation(() => ({ - authenticate: jest.fn(), - constructor: jest.fn(), - })), -})); - -jest.mock('@nestjs/common', () => { - const actual = jest.requireActual('@nestjs/common'); - return { - ...actual, - Logger: jest.fn().mockImplementation(() => ({ - error: jest.fn(), - })), - }; -}); - -jest.mock('../../config/config.service', () => ({ - ConfigService: { - getAuthenticationScope: jest.fn().mockReturnValue('openid profile email'), - }, -})); - -describe('Oauth2Strategy', () => { - const OLD_ENV = process.env; - - beforeEach(() => { - jest.resetModules(); - process.env = { ...OLD_ENV }; - }); - - afterAll(() => { - process.env = OLD_ENV; - }); - - it('should call super with correct options when all env vars are set', () => { - process.env.OAUTH2_CLIENT_AUTH_URL = 'https://auth.example.com'; - process.env.OAUTH2_CLIENT_TOKEN_URL = 'https://token.example.com'; - process.env.OAUTH2_CLIENT_ID = 'clientid'; - process.env.OAUTH2_CLIENT_SECRET = 'secret'; - process.env.OAUTH2_CLIENT_CALLBACKURL = 'https://callback.example.com'; - process.env.OAUTH2_CLIENT_SCOPE = 'openid profile email'; - - const MockedStrategy = require('passport-oauth2').Strategy; - const strategy = new Oauth2Strategy(); - expect(strategy).toBeInstanceOf(Oauth2Strategy); - expect(MockedStrategy).toHaveBeenCalledWith( - expect.objectContaining({ - authorizationURL: 'https://auth.example.com', - tokenURL: 'https://token.example.com', - clientID: 'clientid', - clientSecret: 'secret', - callbackURL: 'https://callback.example.com', - scope: 'openid profile email', - }), - ); - }); - - it('should log error and not call super if env vars are missing', () => { - const LoggerMock = require('@nestjs/common').Logger; - process.env.OAUTH2_CLIENT_AUTH_URL = ''; - const loggerInstance = new LoggerMock(Oauth2Strategy.name); - const errorSpy = jest.spyOn(loggerInstance, 'error'); - new Oauth2Strategy(); - expect(errorSpy).toBeDefined(); - }); - - it('should return profile in validate', async () => { - const strategy = new Oauth2Strategy(); - const profile = { id: '123', name: 'Test' }; - const result = await strategy.validate('token', 'refresh', profile); - expect(result).toBe(profile); - }); - - it('should call super.authenticate in authenticate', () => { - const strategy = new Oauth2Strategy(); - const Strategy = require('passport-oauth2').Strategy; - const superAuthenticate = jest - .spyOn(Strategy.prototype, 'authenticate') - .mockImplementation(); - const req = {} as any; - const options = {}; - strategy.authenticate(req, options); - expect(superAuthenticate).toHaveBeenCalledWith(req, options); - }); -}); diff --git a/server/src/auth/strategies/oauth2.strategy.spec.ts b/server/src/auth/strategies/oauth2.strategy.spec.ts new file mode 100644 index 000000000..761368feb --- /dev/null +++ b/server/src/auth/strategies/oauth2.strategy.spec.ts @@ -0,0 +1,306 @@ +type OAuthCallback = (err: Error | null, body: string | null) => void; + +const createMockOAuth2 = () => ({ + useAuthorizationHeaderforGET: jest.fn(), + get: jest.fn((_url: string, _token: string, callback: OAuthCallback) => + callback(null, '{}'), + ), +}); + +// Mock passport-oauth2 Strategy +const mockOAuth2Instance = createMockOAuth2(); +jest.mock('passport-oauth2', () => { + return { + Strategy: jest.fn().mockImplementation(function (this: any) { + this._oauth2 = mockOAuth2Instance; + this.name = 'oauth2'; + }), + }; +}); + +// Mock @nestjs/passport to return a simple base class +jest.mock('@nestjs/passport', () => ({ + PassportStrategy: () => { + return class MockPassportStrategy { + constructor() { + (this as any)._oauth2 = mockOAuth2Instance; + (this as any).name = 'oauth2'; + } + }; + }, +})); + +jest.mock('@nestjs/common', () => { + const actual = jest.requireActual('@nestjs/common'); + return { + ...actual, + Logger: jest.fn().mockImplementation(() => ({ + error: jest.fn(), + debug: jest.fn(), + })), + Injectable: () => (target: any) => target, + }; +}); + +jest.mock('../../config/config.service', () => ({ + ConfigService: { + getAuthenticationScope: jest.fn().mockReturnValue('openid profile email'), + }, +})); + +describe('Oauth2Strategy', () => { + const OLD_ENV = process.env; + + const setRequiredEnvVars = () => { + process.env.OAUTH2_CLIENT_AUTH_URL = 'https://auth.example.com'; + process.env.OAUTH2_CLIENT_TOKEN_URL = 'https://token.example.com'; + process.env.OAUTH2_CLIENT_ID = 'clientid'; + process.env.OAUTH2_CLIENT_SECRET = 'secret'; + process.env.OAUTH2_CLIENT_CALLBACKURL = 'https://callback.example.com'; + }; + + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + process.env = { ...OLD_ENV }; + // Reset the mock oauth2 instance + mockOAuth2Instance.useAuthorizationHeaderforGET.mockClear(); + mockOAuth2Instance.get.mockClear(); + mockOAuth2Instance.get.mockImplementation( + (_url: string, _token: string, callback: OAuthCallback) => callback(null, '{}'), + ); + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + describe('constructor', () => { + it('should create strategy when all env vars are set', () => { + setRequiredEnvVars(); + + const { Oauth2Strategy } = require('./oauth2.strategy'); + const strategy = new Oauth2Strategy(); + expect(strategy).toBeDefined(); + }); + + it('should throw when required env vars are missing', () => { + delete process.env.OAUTH2_CLIENT_AUTH_URL; + const { Oauth2Strategy } = require('./oauth2.strategy'); + expect(() => new Oauth2Strategy()).toThrow(/OAuth2 strategy requires/); + }); + + it('should not set userProfile when USERINFO_URL is not configured', () => { + setRequiredEnvVars(); + delete process.env.OAUTH2_CLIENT_USERINFO_URL; + + const { Oauth2Strategy } = require('./oauth2.strategy'); + const strategy = new Oauth2Strategy(); + expect(strategy.userProfile).toBeUndefined(); + }); + + it('should set userProfile on instance when USERINFO_URL is configured', () => { + setRequiredEnvVars(); + process.env.OAUTH2_CLIENT_USERINFO_URL = 'https://auth.example.com/userinfo'; + + const { Oauth2Strategy } = require('./oauth2.strategy'); + const strategy = new Oauth2Strategy(); + expect(strategy.userProfile).toBeDefined(); + expect(typeof strategy.userProfile).toBe('function'); + }); + + it('should call useAuthorizationHeaderforGET(true) when USERINFO_URL is set', () => { + setRequiredEnvVars(); + process.env.OAUTH2_CLIENT_USERINFO_URL = 'https://auth.example.com/userinfo'; + + const { Oauth2Strategy } = require('./oauth2.strategy'); + new Oauth2Strategy(); + expect(mockOAuth2Instance.useAuthorizationHeaderforGET).toHaveBeenCalledWith(true); + }); + }); + + describe('validate', () => { + it('should return profile unchanged', async () => { + setRequiredEnvVars(); + + const { Oauth2Strategy } = require('./oauth2.strategy'); + const strategy = new Oauth2Strategy(); + const profile = { id: '123', username: 'testuser' }; + const result = await strategy.validate('token', 'refresh', profile); + expect(result).toBe(profile); + }); + }); + + describe('userProfile', () => { + beforeEach(() => { + setRequiredEnvVars(); + process.env.OAUTH2_CLIENT_USERINFO_URL = 'https://auth.example.com/userinfo'; + }); + + it('should call the correct userinfo URL', (done) => { + mockOAuth2Instance.get.mockImplementation( + (url: string, token: string, callback: OAuthCallback) => { + expect(url).toBe('https://auth.example.com/userinfo'); + expect(token).toBe('test-access-token'); + callback(null, JSON.stringify({ sub: '123', name: 'Test' })); + }, + ); + + const { Oauth2Strategy } = require('./oauth2.strategy'); + const strategy = new Oauth2Strategy(); + + strategy.userProfile('test-access-token', (err: any) => { + expect(err).toBeNull(); + done(); + }); + }); + + it('should parse OIDC userinfo response correctly', (done) => { + mockOAuth2Instance.get.mockImplementation( + (_url: string, _token: string, callback: OAuthCallback) => { + callback( + null, + JSON.stringify({ + sub: 'user-123', + preferred_username: 'testuser', + name: 'Test User', + email: 'test@example.com', + picture: 'https://example.com/avatar.png', + }), + ); + }, + ); + + const { Oauth2Strategy } = require('./oauth2.strategy'); + const strategy = new Oauth2Strategy(); + + strategy.userProfile('access-token', (err: any, profile: any) => { + expect(err).toBeNull(); + expect(profile).toMatchObject({ + id: 'user-123', + username: 'testuser', + displayName: 'Test User', + email: 'test@example.com', + provider: 'oauth2', + }); + expect(profile.emails).toEqual([{ value: 'test@example.com' }]); + expect(profile.photos).toEqual([{ value: 'https://example.com/avatar.png' }]); + expect(profile._raw).toBeDefined(); + expect(profile._json).toBeDefined(); + done(); + }); + }); + + it('should handle userinfo fetch errors', (done) => { + mockOAuth2Instance.get.mockImplementation( + (_url: string, _token: string, callback: OAuthCallback) => { + callback(new Error('Network error'), null); + }, + ); + + const { Oauth2Strategy } = require('./oauth2.strategy'); + const strategy = new Oauth2Strategy(); + + strategy.userProfile('access-token', (err: any, profile: any) => { + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe('Network error'); + expect(profile).toBeUndefined(); + done(); + }); + }); + + it('should handle invalid JSON response', (done) => { + mockOAuth2Instance.get.mockImplementation( + (_url: string, _token: string, callback: OAuthCallback) => { + callback(null, 'not valid json'); + }, + ); + + const { Oauth2Strategy } = require('./oauth2.strategy'); + const strategy = new Oauth2Strategy(); + + strategy.userProfile('access-token', (err: any) => { + expect(err).toBeInstanceOf(SyntaxError); + done(); + }); + }); + + it('should fallback to alternative field names (Gitea-style)', (done) => { + mockOAuth2Instance.get.mockImplementation( + (_url: string, _token: string, callback: OAuthCallback) => { + callback( + null, + JSON.stringify({ + id: 456, + login: 'giteauser', + full_name: 'Gitea User', + avatar_url: 'https://gitea.example.com/avatar/456', + }), + ); + }, + ); + + const { Oauth2Strategy } = require('./oauth2.strategy'); + const strategy = new Oauth2Strategy(); + + strategy.userProfile('access-token', (err: any, profile: any) => { + expect(err).toBeNull(); + expect(profile.id).toBe(456); + expect(profile.username).toBe('giteauser'); + expect(profile.displayName).toBe('Gitea User'); + expect(profile.emails).toEqual([]); + expect(profile.photos).toEqual([{ value: 'https://gitea.example.com/avatar/456' }]); + done(); + }); + }); + + it('should use email as username fallback when login is missing', (done) => { + mockOAuth2Instance.get.mockImplementation( + (_url: string, _token: string, callback: OAuthCallback) => { + callback( + null, + JSON.stringify({ + sub: 'user-789', + email: 'user@example.com', + name: 'Some User', + }), + ); + }, + ); + + const { Oauth2Strategy } = require('./oauth2.strategy'); + const strategy = new Oauth2Strategy(); + + strategy.userProfile('access-token', (err: any, profile: any) => { + expect(err).toBeNull(); + expect(profile.username).toBe('user@example.com'); + expect(profile.displayName).toBe('Some User'); + done(); + }); + }); + + it('should use id as username when no other identifiers available', (done) => { + mockOAuth2Instance.get.mockImplementation( + (_url: string, _token: string, callback: OAuthCallback) => { + callback( + null, + JSON.stringify({ + id: 12345, + name: 'Anonymous User', + }), + ); + }, + ); + + const { Oauth2Strategy } = require('./oauth2.strategy'); + const strategy = new Oauth2Strategy(); + + strategy.userProfile('access-token', (err: any, profile: any) => { + expect(err).toBeNull(); + expect(profile.username).toBe('12345'); + expect(profile.displayName).toBe('Anonymous User'); + done(); + }); + }); + }); +}); diff --git a/server/src/auth/strategies/oauth2.strategy.ts b/server/src/auth/strategies/oauth2.strategy.ts index 744338208..37d85cf1e 100644 --- a/server/src/auth/strategies/oauth2.strategy.ts +++ b/server/src/auth/strategies/oauth2.strategy.ts @@ -1,46 +1,44 @@ import { Strategy, StrategyOptions } from 'passport-oauth2'; import { PassportStrategy } from '@nestjs/passport'; import { Injectable, Logger } from '@nestjs/common'; -import { Request } from 'express'; -import { AuthenticateOptions } from 'passport'; import { ConfigService } from '../../config/config.service'; import * as dotenv from 'dotenv'; dotenv.config(); +export interface UserProfile { + id: string | number; + username: string; + displayName: string; + email?: string; + emails: Array<{ value: string }>; + photos: Array<{ value: string }>; + provider: string; + _raw: string; + _json: Record; +} + +const REQUIRED_ENV_VARS = [ + 'OAUTH2_CLIENT_AUTH_URL', + 'OAUTH2_CLIENT_TOKEN_URL', + 'OAUTH2_CLIENT_ID', + 'OAUTH2_CLIENT_SECRET', + 'OAUTH2_CLIENT_CALLBACKURL', +] as const; + @Injectable() export class Oauth2Strategy extends PassportStrategy(Strategy) { - constructor() { - const logger = new Logger(Oauth2Strategy.name); + private readonly logger = new Logger(Oauth2Strategy.name); - if (!process.env.OAUTH2_CLIENT_AUTH_URL) { - logger.error( - 'OAUTH2_CLIENT_AUTH_URL is not defined in the environment variables', - ); - return; - } - if (!process.env.OAUTH2_CLIENT_TOKEN_URL) { - logger.error( - 'OAUTH2_CLIENT_TOKEN_URL is not defined in the environment variables', - ); - return; - } - if (!process.env.OAUTH2_CLIENT_ID) { - logger.error( - 'OAUTH2_CLIENT_ID is not defined in the environment variables', - ); - return; - } - if (!process.env.OAUTH2_CLIENT_SECRET) { + constructor() { + const missingVars = REQUIRED_ENV_VARS.filter((v) => !process.env[v]); + if (missingVars.length > 0) { + const logger = new Logger(Oauth2Strategy.name); logger.error( - 'OAUTH2_CLIENT_SECRET is not defined in the environment variables', + `Missing required environment variables: ${missingVars.join(', ')}`, ); - return; - } - if (!process.env.OAUTH2_CLIENT_CALLBACKURL) { - logger.error( - 'OAUTH2_CLIENT_CALLBACKURL is not defined in the environment variables', + throw new Error( + `OAuth2 strategy requires: ${missingVars.join(', ')}`, ); - return; } super({ @@ -54,16 +52,55 @@ export class Oauth2Strategy extends PassportStrategy(Strategy) { process.env.OAUTH2_CLIENT_SCOPE, ), } as StrategyOptions); + + // Override userProfile on this instance if userinfo URL is configured + const userinfoUrl = process.env.OAUTH2_CLIENT_USERINFO_URL; + if (userinfoUrl) { + const oauth2 = (this as any)._oauth2; + oauth2.useAuthorizationHeaderforGET(true); + + (this as any).userProfile = ( + accessToken: string, + done: (err: Error | null, profile?: UserProfile) => void, + ): void => { + oauth2.get(userinfoUrl, accessToken, (err: Error | null, body: string) => { + if (err) { + this.logger.error('Failed to fetch userinfo:', err); + return done(err); + } + + try { + const json = JSON.parse(body); + // Use stable identifiers for username (never display name) + // Fallback order: OIDC preferred_username > Gitea login > email > sub/id + const username = + json.preferred_username || + json.login || + json.email || + String(json.sub || json.id); + const profile: UserProfile = { + id: json.sub || json.id, + username, + displayName: json.name || json.full_name || json.preferred_username || username, + email: json.email, + emails: json.email ? [{ value: json.email }] : [], + photos: (json.picture || json.avatar_url) ? [{ value: json.picture || json.avatar_url }] : [], + provider: 'oauth2', + _raw: body, + _json: json, + }; + done(null, profile); + } catch (e) { + this.logger.error('Failed to parse userinfo:', e); + done(e as Error); + } + }); + }; + } } - async validate(accessToken: string, _refreshToken: string, profile: any) { - //TODO: replace 'any' with 'Profile' - console.log('Github2Strategy.validate', profile, accessToken); - //console.log('Github2Strategy.validate', accessToken); + + async validate(_accessToken: string, _refreshToken: string, profile: UserProfile) { + this.logger.debug(`OAuth2 validate: ${profile?.username || profile?.email}`); return profile; } - authenticate(req: Request, options?: AuthenticateOptions): void { - //console.log('Github2Strategy.authenticate', req, options); - //console.log('Github2Strategy.authenticate'); - super.authenticate(req, options); - } }