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 backend/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
createIpWhitelistGate,
} from './services/shared/ipWhitelistService';
import { serverSessionService } from './services/auth/serverSessionService';
import { sessionTokenService } from './services/auth/domain/SessionTokenService';
feat/stellar-wave-issues
import { startPaymentExpirySweeper, type PaymentExpirySweeper } from './services/payment/jobs/paymentExpirySweeper';

Expand Down Expand Up @@ -472,6 +473,8 @@ export async function startServer(options: StartServerOptions = {}): Promise<Run
// ---------------------------------------------------------------------------
const sessionSweepTimer = setInterval(() => {
serverSessionService.sweepExpiredSessions();
// Drop refresh-token families past their absolute lifetime (#1776).
sessionTokenService.sweep();
}, 5 * 60 * 1000);
sessionSweepTimer.unref();

Expand Down
10 changes: 10 additions & 0 deletions backend/server/createApiServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { createLoggerFor } from '../services/shared/logging';
import { createTracerFromEnv } from '../services/shared/tracing';
import { createRequestObservability } from '../shared/middleware/requestObservability';
import { createApiKeyRevocationRouter, createPasskeyRouter } from '../services/auth/router/authRouter';
import { createSessionRouter } from '../services/auth/router/sessionRouter';
import { serveOpenApiSpec, serveSwaggerUI } from '../services/shared/swaggerUI';

// ── Zapier integration ────────────────────────────────────────────────────────
Expand Down Expand Up @@ -230,6 +231,11 @@ export function createApiServer(options: CreateApiServerOptions = {}): Express {
'/api/v1/stripe/billing/webhook',
'/api/v1/paddle/webhook',
'/api/v1/webhooks',
// OAuth endpoints for non-browser clients (#1776, #1779). They carry no
// ambient credentials: the refresh token / device code is in the body,
// so a cross-site request cannot ride on a victim's cookies.
'/api/v1/auth/oauth/token',
'/api/v1/auth/oauth/device_authorization',
],
}) as unknown as express.RequestHandler,
);
Expand Down Expand Up @@ -286,6 +292,10 @@ export function createApiServer(options: CreateApiServerOptions = {}): Express {
// Batch execution is served by `createSubscriptionOpsRouter()` above, which
// already exposes POST /subscriptions/batch and GET /subscriptions/batch/:runId.
app.use('/api/v1/auth', createPasskeyRouter());
// Password fallback, refresh-token rotation, session listing / revocation and
// the OAuth device flow (#1776-#1779). Shares the session + token singletons
// with the passkey router above.
app.use('/api/v1/auth', createSessionRouter());
app.use('/api/v1/api-keys', createApiKeyRevocationRouter());

// ── Zapier integration ─────────────────────────────────────────────────────
Expand Down
240 changes: 240 additions & 0 deletions backend/services/auth/__tests__/DeviceAuthorizationService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import {
DeviceAuthorizationService,
OAuthError,
normalizeUserCode,
} from '../domain/DeviceAuthorizationService';
import { AuthErrorCode } from '../errors';

const CLIENTS = {
cli: {
name: 'SubTrackr CLI',
allowedScopes: ['profile:read', 'subscriptions:read', 'subscriptions:write', 'sessions:read'],
defaultScopes: ['profile:read'],
},
tv: { name: 'TV app', allowedScopes: ['subscriptions:read'] },
evil: { name: 'Misconfigured', allowedScopes: ['*', 'auth:manage', 'profile:read'] },
};

function oauthError(fn: () => unknown): string | undefined {
try {
fn();
} catch (err) {
if (err instanceof OAuthError) return err.error;
return (err as { code?: string }).code;
}
return undefined;
}

describe('DeviceAuthorizationService (RFC 8628)', () => {
let clock: number;
let service: DeviceAuthorizationService;

beforeEach(() => {
clock = 1_700_000_000_000;
service = new DeviceAuthorizationService(
{ clients: CLIENTS, verificationUri: 'https://app.subtrackr.test/device' },
() => clock
);
});

describe('device authorization request', () => {
it('issues device_code, user_code, verification URIs, expiry and interval', () => {
const res = service.requestAuthorization({ clientId: 'cli', scope: 'subscriptions:read' });
expect(res.device_code).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(res.user_code).toMatch(/^[BCDFGHJKLMNPQRSTVWXZ]{4}-[BCDFGHJKLMNPQRSTVWXZ]{4}$/);
expect(res.verification_uri).toBe('https://app.subtrackr.test/device');
expect(res.verification_uri_complete).toBe(
`https://app.subtrackr.test/device?user_code=${res.user_code}`
);
expect(res.expires_in).toBe(600);
expect(res.interval).toBe(5);
});

it('never stores the codes in plaintext', () => {
const res = service.requestAuthorization({ clientId: 'cli' });
const state = JSON.stringify([
...(service as unknown as { grants: Map<string, unknown> }).grants.entries(),
...(service as unknown as { userCodes: Map<string, unknown> }).userCodes.entries(),
]);
expect(state).not.toContain(res.device_code);
expect(state).not.toContain(normalizeUserCode(res.user_code));
});

it('rejects unknown clients and scopes the client may not request', () => {
expect(oauthError(() => service.requestAuthorization({ clientId: 'nope' }))).toBe(
'invalid_client'
);
expect(
oauthError(() => service.requestAuthorization({ clientId: 'tv', scope: 'sessions:read' }))
).toBe('invalid_scope');
// `*` and auth:manage are never delegable, even if a client is misconfigured.
expect(oauthError(() => service.requestAuthorization({ clientId: 'evil', scope: '*' }))).toBe(
'invalid_scope'
);
expect(
oauthError(() => service.requestAuthorization({ clientId: 'evil', scope: 'auth:manage' }))
).toBe('invalid_scope');
});

it('falls back to the client default scopes', () => {
const res = service.requestAuthorization({ clientId: 'cli' });
expect(service.describe(res.user_code, 'u1').scopes).toEqual(['profile:read']);
});
});

describe('polling', () => {
it('walks through authorization_pending → slow_down → tokens exactly once', () => {
const res = service.requestAuthorization({
clientId: 'cli',
scope: 'subscriptions:read sessions:read',
});
const poll = () => service.poll({ clientId: 'cli', deviceCode: res.device_code });

expect(oauthError(poll)).toBe('authorization_pending');
clock += 1000;
expect(oauthError(poll)).toBe('slow_down');
expect(service.getInterval(res.device_code)).toBe(10);
clock += 10_000;
expect(oauthError(poll)).toBe('authorization_pending');

service.approve({ userCode: res.user_code, approverId: 'u1', approverScopes: ['*'] });
clock += 10_000;
expect(poll()).toEqual({
userId: 'u1',
clientId: 'cli',
scopes: ['subscriptions:read', 'sessions:read'],
});
clock += 10_000;
expect(oauthError(poll)).toBe('invalid_grant');
});

it('returns access_denied when the user denies', () => {
const res = service.requestAuthorization({ clientId: 'cli' });
service.deny({ userCode: res.user_code, approverId: 'u1' });
expect(oauthError(() => service.poll({ clientId: 'cli', deviceCode: res.device_code }))).toBe(
'access_denied'
);
expect(oauthError(() => service.poll({ clientId: 'cli', deviceCode: res.device_code }))).toBe(
'invalid_grant'
);
});

it('returns expired_token once the codes lapse, even if approved too late', () => {
const res = service.requestAuthorization({ clientId: 'cli' });
clock += 10 * 60 * 1000 + 1;
expect(
oauthError(() =>
service.approve({ userCode: res.user_code, approverId: 'u1', approverScopes: ['*'] })
)
).toBe(AuthErrorCode.USER_CODE_INVALID);
expect(oauthError(() => service.poll({ clientId: 'cli', deviceCode: res.device_code }))).toBe(
'expired_token'
);
});

it('rejects device codes presented by another client or unknown codes', () => {
const res = service.requestAuthorization({ clientId: 'cli' });
expect(oauthError(() => service.poll({ clientId: 'tv', deviceCode: res.device_code }))).toBe(
'invalid_grant'
);
expect(oauthError(() => service.poll({ clientId: 'cli', deviceCode: 'guess' }))).toBe(
'invalid_grant'
);
expect(
oauthError(() => service.poll({ clientId: 'ghost', deviceCode: res.device_code }))
).toBe('invalid_client');
expect(oauthError(() => service.poll({ clientId: 'cli' }))).toBe('invalid_request');
});
});

describe('approval', () => {
it('accepts the user code case-insensitively and without the dash', () => {
const res = service.requestAuthorization({ clientId: 'cli' });
const typed = res.user_code.replace('-', ' ').toLowerCase();
expect(service.describe(typed, 'u1')).toMatchObject({
clientId: 'cli',
clientName: 'SubTrackr CLI',
});
});

it('makes user codes single-use', () => {
const res = service.requestAuthorization({ clientId: 'cli' });
service.approve({ userCode: res.user_code, approverId: 'u1', approverScopes: ['*'] });
expect(
oauthError(() =>
service.approve({ userCode: res.user_code, approverId: 'u2', approverScopes: ['*'] })
)
).toBe(AuthErrorCode.USER_CODE_INVALID);
expect(oauthError(() => service.deny({ userCode: res.user_code, approverId: 'u1' }))).toBe(
AuthErrorCode.USER_CODE_INVALID
);
});

it('lets the user narrow the requested scopes but never widen them', () => {
const res = service.requestAuthorization({
clientId: 'cli',
scope: 'subscriptions:read subscriptions:write',
});
expect(
oauthError(() =>
service.approve({
userCode: res.user_code,
approverId: 'u1',
approverScopes: ['*'],
scopes: ['subscriptions:read', 'sessions:read'],
})
)
).toBe(AuthErrorCode.USER_CODE_INVALID);

const approved = service.approve({
userCode: res.user_code,
approverId: 'u1',
approverScopes: ['*'],
scopes: ['subscriptions:read'],
});
expect(approved.grantedScopes).toEqual(['subscriptions:read']);
expect(service.poll({ clientId: 'cli', deviceCode: res.device_code }).scopes).toEqual([
'subscriptions:read',
]);
});

it('refuses to grant scopes the approver does not hold', () => {
const res = service.requestAuthorization({ clientId: 'cli', scope: 'subscriptions:write' });
expect(
oauthError(() =>
service.approve({
userCode: res.user_code,
approverId: 'u1',
approverScopes: ['subscriptions:read'],
})
)
).toBe(AuthErrorCode.INSUFFICIENT_SCOPE);
// The failed attempt did not consume the code.
expect(service.describe(res.user_code, 'u1').scopes).toEqual(['subscriptions:write']);
});

it('rate limits repeated wrong user codes per approver', () => {
const res = service.requestAuthorization({ clientId: 'cli' });
for (let i = 0; i < 10; i++) {
expect(oauthError(() => service.describe('BBBB-BBBB', 'brute'))).toBe(
AuthErrorCode.USER_CODE_INVALID
);
}
expect(oauthError(() => service.describe(res.user_code, 'brute'))).toBe(
AuthErrorCode.USER_CODE_ATTEMPTS_EXCEEDED
);
// Other approvers are unaffected.
expect(service.describe(res.user_code, 'u1').clientId).toBe('cli');
clock += 15 * 60 * 1000 + 1;
const fresh = service.requestAuthorization({ clientId: 'cli' });
expect(service.describe(fresh.user_code, 'brute').clientId).toBe('cli');
});
});

it('sweeps grants long past expiry', () => {
service.requestAuthorization({ clientId: 'cli' });
expect(service.sweep()).toBe(0);
clock += 21 * 60 * 1000;
expect(service.sweep()).toBe(1);
});
});
105 changes: 105 additions & 0 deletions backend/services/auth/__tests__/PasswordCredentialService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { PasswordCredentialService } from '../domain/PasswordCredentialService';
import { AuthErrorCode } from '../errors';

// Minimal KDF cost so the suite stays fast; production uses DEFAULT_PASSWORD_POLICY.
const FAST_KDF = { memoryCost: 16, timeCost: 1, parallelism: 1, keyLen: 32 };
const GOOD = 'correct horse battery staple';

function codeOf(fn: () => unknown): string | undefined {
try {
fn();
} catch (err) {
return (err as { code?: string }).code;
}
return undefined;
}

describe('PasswordCredentialService', () => {
let clock: number;
let service: PasswordCredentialService;

beforeEach(() => {
clock = 1_700_000_000_000;
service = new PasswordCredentialService({ kdfParams: FAST_KDF }, () => clock);
});

it('stores a hash, not the password, and authenticates with it', () => {
service.setPassword('u1', GOOD);
expect(service.hasPassword('u1')).toBe(true);
expect(
JSON.stringify(
(service as unknown as { passwords: Map<string, unknown> }).passwords.get('u1')
)
).not.toContain(GOOD);
expect(() => service.authenticate('u1', GOOD)).not.toThrow();
});

it('gives the same error for a wrong password and an unknown user', () => {
service.setPassword('u1', GOOD);
expect(codeOf(() => service.authenticate('u1', 'wrong password!!'))).toBe(
AuthErrorCode.INVALID_CREDENTIALS
);
expect(codeOf(() => service.authenticate('ghost', GOOD))).toBe(
AuthErrorCode.INVALID_CREDENTIALS
);
expect(codeOf(() => service.authenticate(undefined, GOOD))).toBe(
AuthErrorCode.INVALID_CREDENTIALS
);
});

it('enforces the password policy', () => {
expect(codeOf(() => service.setPassword('u1', 'short'))).toBe(
AuthErrorCode.PASSWORD_POLICY_VIOLATION
);
expect(codeOf(() => service.setPassword('u1', 'x'.repeat(257)))).toBe(
AuthErrorCode.PASSWORD_POLICY_VIOLATION
);
expect(codeOf(() => service.setPassword('u1', 42))).toBe(
AuthErrorCode.PASSWORD_POLICY_VIOLATION
);
});

it('requires the current password to change an existing one', () => {
service.setPassword('u1', GOOD);
expect(codeOf(() => service.setPassword('u1', 'a brand new password'))).toBe(
AuthErrorCode.INVALID_CREDENTIALS
);
expect(
codeOf(() => service.setPassword('u1', 'a brand new password', 'not the password'))
).toBe(AuthErrorCode.INVALID_CREDENTIALS);
service.setPassword('u1', 'a brand new password', GOOD);
expect(() => service.authenticate('u1', 'a brand new password')).not.toThrow();
expect(codeOf(() => service.authenticate('u1', GOOD))).toBe(AuthErrorCode.INVALID_CREDENTIALS);
});

it('locks the account after repeated failures, even for the right password', () => {
service.setPassword('u1', GOOD);
for (let i = 0; i < 4; i++) {
expect(codeOf(() => service.authenticate('u1', 'wrong password!!'))).toBe(
AuthErrorCode.INVALID_CREDENTIALS
);
}
expect(codeOf(() => service.authenticate('u1', 'wrong password!!'))).toBe(
AuthErrorCode.ACCOUNT_LOCKED
);
expect(codeOf(() => service.authenticate('u1', GOOD))).toBe(AuthErrorCode.ACCOUNT_LOCKED);

clock += 15 * 60 * 1000 + 1;
expect(() => service.authenticate('u1', GOOD)).not.toThrow();
});

it('forgets old failures outside the window and resets on success', () => {
service.setPassword('u1', GOOD);
for (let i = 0; i < 4; i++) codeOf(() => service.authenticate('u1', 'wrong password!!'));
clock += 16 * 60 * 1000;
expect(codeOf(() => service.authenticate('u1', 'wrong password!!'))).toBe(
AuthErrorCode.INVALID_CREDENTIALS
);
service.authenticate('u1', GOOD);
for (let i = 0; i < 4; i++) {
expect(codeOf(() => service.authenticate('u1', 'wrong password!!'))).toBe(
AuthErrorCode.INVALID_CREDENTIALS
);
}
});
});
Loading
Loading