import { ForbiddenException, ServiceUnavailableException, } from '@nestjs/common'; import { InvalidCredentialsError } from '@task-stream/api/identity/application'; import { UserFactory } from '@task-stream/api/identity/domain'; import { PireonConfig } from './pireon.config'; import { PireonIdentityProvider } from './pireon-identity.provider'; const TOKEN = { access_token: 'access', refresh_token: 'refresh', expires_in: 3600, token_type: 'Bearer', }; /** Pireon clients allow a single grant type: one client for code, one for password. */ function setup(overrides: Partial = {}) { const config: PireonConfig = { baseUrl: 'https://oauth.pireon.dev', sessionTtlSeconds: 60, requestTimeoutMs: 1000, clientId: 'code-client', clientSecret: 'code-secret', passwordClientId: 'password-client', passwordClientSecret: 'password-secret', scopes: ['users:read'], passwordGrant: true, ...overrides, }; const calls: URLSearchParams[] = []; /** Answers 401 to the clients in `rejects`, a token otherwise. */ const respond = (rejects: string[] = []) => jest.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { const body = init?.body as URLSearchParams; calls.push(body); return rejects.includes(body.get('client_id') ?? '') ? new Response( JSON.stringify({ error: 'invalid_request', error_description: 'The refresh token is invalid.', }), { status: 401 }, ) : new Response(JSON.stringify(TOKEN), { status: 200 }); }); return { provider: new PireonIdentityProvider(config, new UserFactory()), calls, respond, }; } const password = { type: 'password', username: 'ana@pireon.dev', password: 'x', } as const; const code = { type: 'authorization_code', code: 'c', codeVerifier: 'v', redirectUri: 'https://taskstream.pireon.dev/auth/callback', } as const; const refresh = { type: 'refresh_token', refreshToken: 'r' } as const; describe('PireonIdentityProvider tokens', () => { afterEach(() => jest.restoreAllMocks()); it('uses the password client for email + password', async () => { const { provider, calls, respond } = setup(); respond(); await provider.issueToken(password); expect(calls[0].get('client_id')).toBe('password-client'); expect(calls[0].get('client_secret')).toBe('password-secret'); expect(calls[0].get('grant_type')).toBe('password'); }); it('uses the authorization_code client for "Sign in with Pireon"', async () => { const { provider, calls, respond } = setup(); respond(); await provider.issueToken(code); expect(calls[0].get('client_id')).toBe('code-client'); expect(calls[0].get('code_verifier')).toBe('v'); }); it('falls back to the main client when there is no password client', async () => { const { provider, calls, respond } = setup({ passwordClientId: undefined, passwordClientSecret: undefined, }); respond(); await provider.issueToken(password); expect(calls[0].get('client_id')).toBe('code-client'); }); it('refreshes with whichever client issued the token', async () => { const { provider, calls, respond } = setup(); respond(['password-client']); await expect(provider.issueToken(refresh)).resolves.toMatchObject({ accessToken: 'access', }); expect(calls.map((c) => c.get('client_id'))).toEqual([ 'password-client', 'code-client', ]); }); it("keeps Pireon's hint, which says what was wrong", async () => { const { provider } = setup(); jest.spyOn(globalThis, 'fetch').mockResolvedValue( new Response( JSON.stringify({ error: 'invalid_request', error_description: 'The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed.', hint: 'Invalid redirect URI', }), { status: 400 }, ), ); await expect(provider.issueToken(code)).rejects.toThrow( /otherwise malformed\. \(Invalid redirect URI\)/, ); }); it('rejects the refresh when no client accepts it', async () => { const { provider, respond } = setup(); respond(['password-client', 'code-client']); await expect(provider.issueToken(refresh)).rejects.toBeInstanceOf( InvalidCredentialsError, ); }); it('refuses email + password when PIREON_PASSWORD_GRANT=false', async () => { const { provider, calls, respond } = setup({ passwordGrant: false }); respond(); await expect(provider.issueToken(password)).rejects.toBeInstanceOf( ForbiddenException, ); expect(calls).toHaveLength(0); }); it('says what is missing when a flow is not configured', async () => { const { provider } = setup({ clientId: undefined, clientSecret: undefined, }); await expect(provider.issueToken(code)).rejects.toBeInstanceOf( ServiceUnavailableException, ); }); it('tells the browser which flows exist', () => { expect(setup().provider.authorizationConfig()).toMatchObject({ clientId: 'code-client', passwordGrant: true, }); expect( setup({ clientId: undefined, clientSecret: undefined, }).provider.authorizationConfig(), ).toMatchObject({ clientId: null, passwordGrant: true }); expect( setup({ clientId: undefined, passwordClientId: undefined, }).provider.authorizationConfig(), ).toBeNull(); }); });