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
3 changes: 3 additions & 0 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ The connection-role migration preserves a sole eligible connection, prefers an u
- `GOOGLE_PLAY_RTDN_PUSH_AUDIENCE` - Expected OIDC audience for Play Real-time Developer Notification Pub/Sub push (the HTTPS URL of POST /api/kilo-pass/play/notifications). `[SERVER]`
- `GOOGLE_PLAY_RTDN_PUSH_SERVICE_ACCOUNT_EMAIL` - Expected service-account email for the Play RTDN Pub/Sub push OIDC token; the token `email` claim must match it. [SERVER]
- `APPLE_APP_BUNDLE_ID` - iOS app bundle ID for Apple App Attest and Sign In verification. [SERVER]
- `APPLE_JWKS_URL_OVERRIDE` - Apple JWKS endpoint override for local verification runs; applies only outside production deployments. [SERVER]
- `NATIVE_APPLE_NONCE_REQUIRED_SINCE` - ISO 8601 instant; the native Apple sign-in nonce requirement applies from this instant onward. Before it, requests are accepted without the field while the `native-apple-signin-nonce-relaxed-path` release toggle is enabled. Unset, invalid, or past ⇒ the requirement applies unconditionally. [SERVER]
- `NATIVE_APPLE_NONCE_TRANSITION_LOCAL` - When `true` in a development environment outside Vercel, the transition window opens without the release toggle, for local verification runs without PostHog credentials. [SERVER]
- `NATIVE_ADMISSION_DEV_SENTINEL` - When `true`, non-production accepts the fixed device-attestation sentinel from devices that cannot attest (simulator, emulator without Play Services). Ignored when `NODE_ENV=production`. The mobile build reads the same name. [SERVER]
- `GOOGLE_PLAY_INTEGRITY_PROJECT_NUMBER` - Google Cloud project number for Play Integrity API. Also read by the mobile build, where it ships in the bundle; it is an identifier, not a secret. [SERVER]
- `GOOGLE_PLAY_INTEGRITY_SERVICE_ACCOUNT_KEY` - Service account JSON key for Play Integrity API. `[SECRET]`
Expand Down
75 changes: 44 additions & 31 deletions apps/web/src/app/api/auth/native/token/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
verifyNativeGoogleIdToken,
exchangeNativeGoogleAuthCode,
NativeIdTokenError,
NonceStoreUnavailableError,
} from '@/lib/auth/native-id-tokens';
import {
reserveSignInCode,
Expand Down Expand Up @@ -165,13 +166,18 @@ describe('POST /api/auth/native/token', () => {
});

const response = await POST(
createRequest({ provider: 'apple', idToken: 'apple-id-token', fullName: 'Jane Doe' })
createRequest({
provider: 'apple',
idToken: 'apple-id-token',
fullName: 'Jane Doe',
nonce: 'test-nonce',
})
);
const data = await response.json();

expect(response.status).toBe(200);
expect(data).toEqual({ token: 'minted-jwt', created: false });
expect(mockVerifyNativeAppleIdToken).toHaveBeenCalledWith('apple-id-token', undefined);
expect(mockVerifyNativeAppleIdToken).toHaveBeenCalledWith('apple-id-token', 'test-nonce');
expect(mockCreateOrUpdateUser).toHaveBeenCalledWith(
expect.objectContaining({
google_user_email: 'appleuser@example.com',
Expand All @@ -197,7 +203,9 @@ describe('POST /api/auth/native/token', () => {
email: 'appleuser@example.com',
});

await POST(createRequest({ provider: 'apple', idToken: 'apple-id-token' }));
await POST(
createRequest({ provider: 'apple', idToken: 'apple-id-token', nonce: 'test-nonce' })
);

expect(mockCreateOrUpdateUser).toHaveBeenCalledWith(
expect.objectContaining({ google_user_name: 'appleuser' }),
Expand All @@ -214,7 +222,9 @@ describe('POST /api/auth/native/token', () => {
it('returns 401 INVALID_TOKEN when Apple verification throws NativeIdTokenError', async () => {
mockVerifyNativeAppleIdToken.mockRejectedValue(new NativeIdTokenError('bad token'));

const response = await POST(createRequest({ provider: 'apple', idToken: 'bad-token' }));
const response = await POST(
createRequest({ provider: 'apple', idToken: 'bad-token', nonce: 'test-nonce' })
);
const data = await response.json();

expect(response.status).toBe(401);
Expand All @@ -226,7 +236,7 @@ describe('POST /api/auth/native/token', () => {
mockVerifyNativeAppleIdToken.mockRejectedValue(new Error('network'));

await expect(
POST(createRequest({ provider: 'apple', idToken: 'apple-id-token' }))
POST(createRequest({ provider: 'apple', idToken: 'apple-id-token', nonce: 'test-nonce' }))
).rejects.toThrow('network');
expect(mockCreateOrUpdateUser).not.toHaveBeenCalled();
});
Expand All @@ -243,7 +253,9 @@ describe('POST /api/auth/native/token', () => {
ssoOrganizationId: 'workos-organization-id',
});

const response = await POST(createRequest({ provider: 'apple', idToken: 'apple-id-token' }));
const response = await POST(
createRequest({ provider: 'apple', idToken: 'apple-id-token', nonce: 'test-nonce' })
);
const data = await response.json();

expect(response.status).toBe(403);
Expand All @@ -263,20 +275,30 @@ describe('POST /api/auth/native/token', () => {
errorCode: 'BLOCKED',
});

const response = await POST(createRequest({ provider: 'apple', idToken: 'apple-id-token' }));
const response = await POST(
createRequest({ provider: 'apple', idToken: 'apple-id-token', nonce: 'test-nonce' })
);
const data = await response.json();

expect(response.status).toBe(403);
expect(data).toEqual({ error: 'BLOCKED' });
expect(mockCreateOrUpdateUser).not.toHaveBeenCalled();
});

// C12: nonce forwarding
it('passes the raw nonce to verifyNativeAppleIdToken', async () => {
mockVerifyNativeAppleIdToken.mockResolvedValue({
sub: 'apple-sub-1',
email: 'appleuser@example.com',
});
it('returns 401 INVALID_TOKEN when the client sends no nonce and the verifier refuses', async () => {
mockVerifyNativeAppleIdToken.mockRejectedValue(new NativeIdTokenError('Apple nonce missing'));

const response = await POST(createRequest({ provider: 'apple', idToken: 'apple-id-token' }));

expect(response.status).toBe(401);
expect(await response.json()).toEqual({ error: 'INVALID_TOKEN' });
expect(mockVerifyNativeAppleIdToken).toHaveBeenCalledWith('apple-id-token', undefined);
});

it('returns 503 NONCE_STORE_UNAVAILABLE when the nonce record cannot be written', async () => {
mockVerifyNativeAppleIdToken.mockRejectedValue(
new NonceStoreUnavailableError('Apple nonce check unavailable')
);

const response = await POST(
createRequest({
Expand All @@ -286,23 +308,9 @@ describe('POST /api/auth/native/token', () => {
})
);

expect(response.status).toBe(200);
expect(mockVerifyNativeAppleIdToken).toHaveBeenCalledWith(
'apple-id-token',
'raw-nonce-from-client'
);
});

it('provides no nonce when the client sends none (legacy)', async () => {
mockVerifyNativeAppleIdToken.mockResolvedValue({
sub: 'apple-sub-1',
email: 'appleuser@example.com',
});

const response = await POST(createRequest({ provider: 'apple', idToken: 'apple-id-token' }));

expect(response.status).toBe(200);
expect(mockVerifyNativeAppleIdToken).toHaveBeenCalledWith('apple-id-token', undefined);
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ error: 'NONCE_STORE_UNAVAILABLE' });
expect(mockCreateOrUpdateUser).not.toHaveBeenCalled();
});
});

Expand Down Expand Up @@ -1389,6 +1397,7 @@ describe('POST /api/auth/native/token', () => {
createRequest({
provider: 'apple',
idToken: 'apple-id-token',
nonce: 'test-nonce',
admission: {},
})
);
Expand Down Expand Up @@ -1423,6 +1432,7 @@ describe('POST /api/auth/native/token', () => {
createRequest({
provider: 'apple',
idToken: 'apple-id-token',
nonce: 'test-nonce',
supportsRefresh: true,
admission: {},
})
Expand Down Expand Up @@ -1464,6 +1474,7 @@ describe('POST /api/auth/native/token', () => {
createRequest({
provider: 'apple',
idToken: 'apple-id-token',
nonce: 'test-nonce',
supportsRefresh: true,
admission: {},
})
Expand Down Expand Up @@ -1706,7 +1717,9 @@ describe('POST /api/auth/native/token', () => {
isNew: true,
} as never);

const response = await POST(createRequest({ provider: 'apple', idToken: 'apple-id-token' }));
const response = await POST(
createRequest({ provider: 'apple', idToken: 'apple-id-token', nonce: 'test-nonce' })
);

expect(response.status).toBe(200);
expect(await response.json()).toEqual({ token: 'minted-jwt', created: true });
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/app/api/auth/native/token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
verifyNativeGoogleIdToken,
exchangeNativeGoogleAuthCode,
NativeIdTokenError,
NonceStoreUnavailableError,
} from '@/lib/auth/native-id-tokens';
import { AppleJwtClientError } from '@/lib/auth/apple-jwks';
import {
Expand Down Expand Up @@ -166,6 +167,7 @@ const requestSchema = z.discriminatedUnion('provider', [
* 401 { error: 'INVALID_TICKET' }
* 425 { error: 'CODE_IN_PROGRESS' }
* 429 { error: 'TOO_MANY_ATTEMPTS' }
* 503 { error: 'NONCE_STORE_UNAVAILABLE' }
* 403/503 { error: 'BLOCKED' | 'SSO_ERROR', ssoOrganizationId? }
* 403 { error: AuthErrorType }
* 403 { error: 'ADMISSION_REQUIRED' }
Expand Down Expand Up @@ -270,6 +272,9 @@ export const POST = withRestTiming('/api/auth/native/token', async (request: Req
try {
verified = await verifyNativeAppleIdToken(data.idToken, data.nonce);
} catch (error) {
if (error instanceof NonceStoreUnavailableError) {
return NextResponse.json({ error: 'NONCE_STORE_UNAVAILABLE' }, { status: 503 });
}
if (!isInvalidNativeTokenError(error)) {
throw error;
}
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/lib/auth/apple-jwks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,35 @@ describe('verifyAppleJwtWithJwks', () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it('refreshes the key set at cache expiry', async () => {
const { verifyAppleJwtWithJwks } = await loadFreshModule();
let now = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => now);
const fetchMock = jest
.spyOn(global, 'fetch')
.mockResolvedValueOnce(jwksResponse([jwkForKey('current-key')]))
.mockResolvedValueOnce(jwksResponse([jwkForKey('renewed-key')]));

await expect(
verifyAppleJwtWithJwks(signToken('current-key', { sub: 'fixture' }), 'com.kilocode.kiloapp')
).resolves.toMatchObject({ sub: 'fixture' });

now += 60 * 60 * 1000 - 1;
await expect(
verifyAppleJwtWithJwks(signToken('current-key', { sub: 'fixture' }), 'com.kilocode.kiloapp')
).resolves.toMatchObject({ sub: 'fixture' });
expect(fetchMock).toHaveBeenCalledTimes(1);

now += 1;
await expect(
verifyAppleJwtWithJwks(signToken('renewed-key', { sub: 'fixture' }), 'com.kilocode.kiloapp')
).resolves.toMatchObject({ sub: 'fixture' });
await expect(
verifyAppleJwtWithJwks(signToken('current-key'), 'com.kilocode.kiloapp')
).rejects.toThrow('No matching Apple public key');
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it('keeps the cached key set usable when a refresh fails', async () => {
const { verifyAppleJwtWithJwks, AppleJwtClientError } = await loadFreshModule();
let now = 1_700_000_000_000;
Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/lib/auth/apple-jwks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ import jwt from 'jsonwebtoken';
import { createPublicKey } from 'node:crypto';

const APPLE_JWKS_URL = 'https://appleid.apple.com/auth/keys';
// Local verification runs can serve a fixture key set through this override.
// The override applies only outside production deployments.
function appleJwksUrl(): string {
const override = process.env['APPLE_JWKS_URL_OVERRIDE'];
if (override && process.env.NODE_ENV !== 'production' && !process.env.VERCEL_ENV) {
return override;
}
return APPLE_JWKS_URL;
}

/** Thrown for malformed tokens or unresolvable keys — caller error, not a server fault. */
export class AppleJwtClientError extends Error {}
Expand All @@ -24,7 +33,7 @@ const REFRESH_COOLDOWN_MS = 30 * 1000;
const JWKS_FETCH_TIMEOUT_MS = 10_000;

async function fetchApplePublicKeys(): Promise<AppleJWK[]> {
const response = await fetch(APPLE_JWKS_URL, {
const response = await fetch(appleJwksUrl(), {
signal: AbortSignal.timeout(JWKS_FETCH_TIMEOUT_MS),
});
if (!response.ok) {
Expand Down
Loading
Loading