Passport answers token failures with a generic `invalid_request` description and puts the actual cause in `hint` (invalid redirect URI, expired or revoked code, code issued to another client…). The proxy dropped it, so sign-in showed only "The request is missing a required parameter…". It is now appended to the error message and logged with the grant type and client id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
5.5 KiB
TypeScript
173 lines
5.5 KiB
TypeScript
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<PireonConfig> = {}) {
|
|
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();
|
|
});
|
|
});
|