Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions apps/api/src/auth/__tests__/auth-failure-contract.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import {
ExecutionContext,
ForbiddenException,
UnauthorizedException,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { JwtAuthGuard } from "../guards/jwt-auth.guard";
import { RolesGuard } from "../guards/roles.guard";
import { ROLES_KEY } from "../decorators/roles.decorator";

/**
* Contract: every auth failure is a 401/403 with a JSON body of
* `{ error, message, timestamp }` (ISO-8601), and never echoes credentials.
*/
describe("API authentication failure contract", () => {
const reflector = { getAllAndOverride: jest.fn() } as unknown as Reflector;
const jwtGuard = new JwtAuthGuard(reflector);
const rolesGuard = new RolesGuard(reflector);

const contextWith = (user?: unknown): ExecutionContext =>
({
getHandler: jest.fn(),
getClass: jest.fn(),
switchToHttp: () => ({ getRequest: () => ({ user }) }),
}) as unknown as ExecutionContext;

const capture = (fn: () => unknown) => {
try {
fn();
} catch (e) {
return e as UnauthorizedException | ForbiddenException;
}
throw new Error("expected the call to throw");
};

const expectContract = (
e: UnauthorizedException | ForbiddenException,
status: number,
error: string,
) => {
expect(e.getStatus()).toBe(status);
const body = e.getResponse() as Record<string, string>;
expect(Object.keys(body).sort()).toEqual(["error", "message", "timestamp"]);
expect(body.error).toBe(error);
expect(typeof body.message).toBe("string");
expect(new Date(body.timestamp).toISOString()).toBe(body.timestamp);
return body;
};

describe("401 Unauthorized (JwtAuthGuard)", () => {
it.each([
["missing token", null, { message: "No auth token" }, "No auth token"],
["expired token", null, { name: "TokenExpiredError" }, "JWT access token has expired."],
["malformed token", null, { name: "JsonWebTokenError" }, "Invalid JWT access token format."],
["no failure detail", null, undefined, "Invalid or expired JWT access token."],
])("%s", (_name, user, info, message) => {
const body = expectContract(
capture(() => jwtGuard.handleRequest(null, user, info)),
401,
"Unauthorized",
);
expect(body.message).toBe(message);
});

it("rejects when the strategy errors even if a user is present", () => {
expectContract(
capture(() => jwtGuard.handleRequest(new Error("boom"), { userId: "u" }, undefined)),
401,
"Unauthorized",
);
});

it("does not leak the underlying error text or token", () => {
const body = expectContract(
capture(() =>
jwtGuard.handleRequest(new Error("secret-token-abc"), null, undefined),
),
401,
"Unauthorized",
);
expect(JSON.stringify(body)).not.toContain("secret-token-abc");
});

it("passes an authenticated user through unchanged", () => {
const user = { userId: "u1", roles: [], permissions: [] };
expect(jwtGuard.handleRequest(null, user, undefined)).toBe(user);
});
});

describe("403 Forbidden (RolesGuard)", () => {
beforeEach(() => {
(reflector.getAllAndOverride as jest.Mock).mockImplementation((key: string) =>
key === ROLES_KEY ? ["admin"] : undefined,
);
});

it("unauthenticated request to a role-protected route", () => {
expectContract(capture(() => rolesGuard.canActivate(contextWith())), 403, "Forbidden");
});

it("authenticated user without the role", () => {
const body = expectContract(
capture(() =>
rolesGuard.canActivate(contextWith({ userId: "u", roles: ["viewer"], permissions: [] })),
),
403,
"Forbidden",
);
expect(body.message).toContain("admin");
});

it("user with no roles property", () => {
expectContract(
capture(() => rolesGuard.canActivate(contextWith({ userId: "u" }))),
403,
"Forbidden",
);
});

it("allows a user with the role", () => {
expect(
rolesGuard.canActivate(contextWith({ userId: "u", roles: ["admin"], permissions: [] })),
).toBe(true);
});
});
});
28 changes: 28 additions & 0 deletions docs/WEBHOOK_SIGNATURE_ROTATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Webhook Signature Rotation

`WebhookSignatureRotation` (`src/integrations/webhooks`) signs payloads with the
current secret and keeps the previous secret valid for verification during a grace
window, so receivers can roll over without dropping deliveries.

```typescript
const rotation = new WebhookSignatureRotation(secret, { graceMs: 24 * 60 * 60 * 1000 });
const header = rotation.sign(body); // "sha256=<hex>"
rotation.rotate(); // new random secret; old one valid until grace ends
rotation.verify(body, header); // { valid, matched: 'current' | 'previous', reason? }
```

## Configuration

| Option | Default | Notes |
| --------- | ------- | ---------------------------------- |
| `graceMs` | 24h | Must be a non-negative number |
| `now` | `Date.now` | Clock injection for tests |

## Failure semantics

- Secrets shorter than 16 characters, or a rotation to the same secret, throw and leave state unchanged.
- `verify` never throws: malformed signatures return `reason: 'malformed'`, wrong ones `reason: 'mismatch'`.
- Only one previous secret is retained; a second rotation drops the oldest.
- The previous secret is rejected from the grace deadline onward.
- Comparison is timing-safe. State is in-memory and per instance, so tenants must use separate instances.
- Backward compatible: the header format (`sha256=<hex>`) matches the existing `X-GasGuard-Signature`.
26 changes: 26 additions & 0 deletions src/integrations/github/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -479,3 +479,29 @@ if (!result.valid) {
console.log('Remediation:', result.details);
}
```

## GitHub Enterprise Server

Set `baseUrl` on the auth config to target a GHES instance. Both the host URL and
the API URL are accepted and normalized to `<host>/api/v3`; omitting it keeps
`https://api.github.com`.

```typescript
const client = new GitHubClient({
token: process.env.GHES_TOKEN,
baseUrl: 'https://ghe.example.com',
});
```

Failure semantics: a non-https URL, embedded credentials, a query/fragment, or a
path other than `/api/v3` throws at construction. Existing configs without
`baseUrl` are unchanged.

## Token expiration diagnostics

`client.getTokenExpiration(warnDays = 7)` reads the
`github-authentication-token-expiration` header and returns
`{ status, expiresAt?, msRemaining?, message }` where `status` is one of
`no_expiry`, `valid`, `expiring_soon`, `expired`, `invalid`. `diagnoseTokenExpiration`
is exported for use on a header value directly. Tokens are never included in
messages. Request errors (e.g. 401) propagate from the client unchanged.
63 changes: 63 additions & 0 deletions src/integrations/github/__tests__/enterprise-config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import axios from 'axios';
import { resolveGitHubApiBaseUrl, GITHUB_COM_API_URL } from '../enterprise-config';
import { GitHubClient } from '../github-client';

jest.mock('axios');
jest.mock('../../../security/dns', () => ({ createSafeAgent: jest.fn() }));

describe('resolveGitHubApiBaseUrl', () => {
it('defaults to github.com', () => {
expect(resolveGitHubApiBaseUrl()).toBe(GITHUB_COM_API_URL);
expect(resolveGitHubApiBaseUrl('')).toBe(GITHUB_COM_API_URL);
expect(resolveGitHubApiBaseUrl('https://api.github.com')).toBe(GITHUB_COM_API_URL);
});

it('appends /api/v3 to a GHES host URL', () => {
expect(resolveGitHubApiBaseUrl('https://ghe.example.com')).toBe('https://ghe.example.com/api/v3');
expect(resolveGitHubApiBaseUrl('https://ghe.example.com/')).toBe('https://ghe.example.com/api/v3');
expect(resolveGitHubApiBaseUrl('https://ghe.example.com:8443')).toBe(
'https://ghe.example.com:8443/api/v3',
);
});

it('accepts an explicit API URL', () => {
expect(resolveGitHubApiBaseUrl('https://ghe.example.com/api/v3')).toBe(
'https://ghe.example.com/api/v3',
);
expect(resolveGitHubApiBaseUrl('https://ghe.example.com/api/v3/')).toBe(
'https://ghe.example.com/api/v3',
);
});

it('rejects unsupported input', () => {
expect(() => resolveGitHubApiBaseUrl('not a url')).toThrow(/Invalid/);
expect(() => resolveGitHubApiBaseUrl('http://ghe.example.com')).toThrow(/https/);
expect(() => resolveGitHubApiBaseUrl('https://user:pw@ghe.example.com')).toThrow(/credentials/);
expect(() => resolveGitHubApiBaseUrl('https://ghe.example.com?x=1')).toThrow(/query/);
expect(() => resolveGitHubApiBaseUrl('https://ghe.example.com/api/v4')).toThrow(/path/);
});
});

describe('GitHubClient base URL', () => {
beforeEach(() => {
(axios.create as jest.Mock).mockReturnValue({
interceptors: { response: { use: jest.fn() } },
});
});

it('keeps github.com when baseUrl is not set', () => {
new GitHubClient({ token: 't' });
expect((axios.create as jest.Mock).mock.calls.pop()[0].baseURL).toBe(GITHUB_COM_API_URL);
});

it('uses the configured GHES API URL', () => {
new GitHubClient({ token: 't', baseUrl: 'https://ghe.example.com' });
expect((axios.create as jest.Mock).mock.calls.pop()[0].baseURL).toBe(
'https://ghe.example.com/api/v3',
);
});

it('fails fast on an invalid baseUrl', () => {
expect(() => new GitHubClient({ baseUrl: 'http://ghe.example.com' })).toThrow(/https/);
});
});
75 changes: 75 additions & 0 deletions src/integrations/github/__tests__/token-expiration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {
diagnoseTokenExpiration,
parseTokenExpiration,
DEFAULT_EXPIRY_WARNING_DAYS,
} from '../token-expiration';

describe('token expiration diagnostics', () => {
const now = new Date('2030-01-01T00:00:00Z');
const DAY = 24 * 60 * 60 * 1000;

describe('parseTokenExpiration', () => {
it('parses UTC and offset formats', () => {
expect(parseTokenExpiration('2030-01-10 12:00:00 UTC')?.toISOString()).toBe(
'2030-01-10T12:00:00.000Z',
);
expect(parseTokenExpiration('2030-01-10 12:00:00 -0500')?.toISOString()).toBe(
'2030-01-10T17:00:00.000Z',
);
expect(parseTokenExpiration('2030-01-10 12:00:00 +0530')?.toISOString()).toBe(
'2030-01-10T06:30:00.000Z',
);
});

it('returns undefined for unsupported formats', () => {
expect(parseTokenExpiration('tomorrow')).toBeUndefined();
expect(parseTokenExpiration('2030-01-10')).toBeUndefined();
expect(parseTokenExpiration('2030-13-45 99:00:00 UTC')).toBeUndefined();
});
});

describe('diagnoseTokenExpiration', () => {
it('reports no_expiry when the header is absent or blank', () => {
for (const value of [undefined, null, '', ' ']) {
expect(diagnoseTokenExpiration(value, now).status).toBe('no_expiry');
}
});

it('reports valid when expiry is beyond the warning window', () => {
const result = diagnoseTokenExpiration('2030-02-01 00:00:00 UTC', now);
expect(result.status).toBe('valid');
expect(result.msRemaining).toBe(31 * DAY);
});

it('reports expiring_soon inside the warning window, including the boundary', () => {
expect(diagnoseTokenExpiration('2030-01-03 00:00:00 UTC', now).status).toBe('expiring_soon');
expect(
diagnoseTokenExpiration('2030-01-08 00:00:00 UTC', now, DEFAULT_EXPIRY_WARNING_DAYS).status,
).toBe('expiring_soon');
expect(diagnoseTokenExpiration('2030-01-08 00:00:01 UTC', now).status).toBe('valid');
});

it('reports expired at and after the expiry instant', () => {
expect(diagnoseTokenExpiration('2030-01-01 00:00:00 UTC', now).status).toBe('expired');
const past = diagnoseTokenExpiration('2029-12-31 00:00:00 UTC', now);
expect(past.status).toBe('expired');
expect(past.msRemaining).toBe(-DAY);
});

it('honours a custom warning window', () => {
expect(diagnoseTokenExpiration('2030-01-20 00:00:00 UTC', now, 30).status).toBe('expiring_soon');
expect(diagnoseTokenExpiration('2030-01-02 00:00:00 UTC', now, 0).status).toBe('valid');
});

it('reports invalid for an unparseable header without echoing it', () => {
const result = diagnoseTokenExpiration('ghp_secret-looking-value', now);
expect(result.status).toBe('invalid');
expect(result.message).not.toContain('ghp_');
});

it('rejects an invalid warning window', () => {
expect(() => diagnoseTokenExpiration('2030-02-01 00:00:00 UTC', now, -1)).toThrow(/warnDays/);
expect(() => diagnoseTokenExpiration('2030-02-01 00:00:00 UTC', now, NaN)).toThrow(/warnDays/);
});
});
});
45 changes: 45 additions & 0 deletions src/integrations/github/enterprise-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* GitHub Enterprise Server Configuration
*
* Resolves the REST API base URL for github.com or a GitHub Enterprise
* Server (GHES) instance.
*/

export const GITHUB_COM_API_URL = 'https://api.github.com';
export const GHES_API_PATH = '/api/v3';

/**
* Accepts a GHES host URL (`https://ghe.example.com`) or its API URL
* (`https://ghe.example.com/api/v3`) and returns the normalized API URL.
* Omitting the value keeps github.com. Only https is accepted.
*/
export function resolveGitHubApiBaseUrl(baseUrl?: string): string {
if (baseUrl === undefined || baseUrl === '') {
return GITHUB_COM_API_URL;
}

let url: URL;
try {
url = new URL(baseUrl);
} catch {
throw new Error('Invalid GitHub Enterprise Server URL');
}

if (url.protocol !== 'https:') {
throw new Error('GitHub Enterprise Server URL must use https');
}
if (url.username || url.password || url.search || url.hash) {
throw new Error('GitHub Enterprise Server URL must not include credentials, query or fragment');
}

const path = url.pathname.replace(/\/+$/, '');
if (path !== '' && path !== GHES_API_PATH) {
throw new Error(`GitHub Enterprise Server URL path must be empty or ${GHES_API_PATH}`);
}

// api.github.com has no /api/v3 prefix.
if (url.hostname === 'api.github.com' && path === '') {
return GITHUB_COM_API_URL;
}
return `${url.origin}${GHES_API_PATH}`;
}
Loading
Loading