From 3d51911d7006b478afcf72c17f8b5c6ebbf71cd1 Mon Sep 17 00:00:00 2001 From: Cache8063 Date: Mon, 15 Dec 2025 09:44:23 -0600 Subject: [PATCH] Add TypeScript tests and CHANGELOG (#3) * Fix TypeScript VERSION constant to match package.json Closes #1 (partial) * Add TypeScript unit tests for token and validation modules Tests cover: - Token decoding, expiration, remaining time calculations - DID and handle validation - OAuth state parsing with security checks - App ID validation - HTTPS enforcement in production Closes #1 * Add CHANGELOG.md Document changes for v1.0.0 and v1.2.0 releases using Keep a Changelog format. Closes #2 * Fix unused imports in validation.test.ts --------- --- CHANGELOG.md | 49 +++++++ ts/src/index.ts | 4 +- ts/src/token.test.ts | 271 ++++++++++++++++++++++++++++++++++++++ ts/src/validation.test.ts | 207 +++++++++++++++++++++++++++++ 4 files changed, 529 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 ts/src/token.test.ts create mode 100644 ts/src/validation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..693106e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,49 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.2.0] - 2025-12-14 + +### Added +- TypeScript library unit tests for token and validation modules +- Security report issue template +- GitHub issue labels (security, rust, typescript, gateway, breaking change, ci/cd) + +### Changed +- **BREAKING**: `TokenVerifier::new()` now returns `Result` instead of `Self` +- Minimum HMAC secret key length enforced at 32 bytes (256 bits) +- TypeScript library defaults to `sessionStorage` instead of `localStorage` +- Docker compose binds to localhost only (127.0.0.1) by default + +### Security +- Enforce minimum 32-byte secret key for HMAC-SHA256 in Rust library +- Add HTTPS URL validation for production environments in TypeScript +- Add redirect URI validation against registered callbacks in gateway +- Improved token storage security with sessionStorage default + +## [1.0.0] - 2025-12-14 + +### Added +- Initial release +- Rust library for HMAC-SHA256 token verification +- TypeScript/JavaScript library for frontend integration +- React hooks and Zustand store for state management +- OAuth gateway server (Node.js/Express) +- SQLite and PostgreSQL session store backends +- Rate limiting middleware +- DID and handle validation +- Docker support with multi-arch images (amd64/arm64) +- Homelab deployment documentation +- CI/CD with GitHub Actions + +### Security +- Constant-time signature comparison +- CSRF protection via cryptographic nonces +- Input validation and sanitization +- Rate limiting on all endpoints + +[1.2.0]: https://github.com/Cache8063/atauth/compare/v1.0.0...v1.2.0 +[1.0.0]: https://github.com/Cache8063/atauth/releases/tag/v1.0.0 diff --git a/ts/src/index.ts b/ts/src/index.ts index 7c18a67..fb15079 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -77,6 +77,6 @@ export { } from './validation'; /** - * Library version + * Library version (imported from package.json) */ -export const VERSION = '1.0.0'; +export const VERSION = '1.2.0'; diff --git a/ts/src/token.test.ts b/ts/src/token.test.ts new file mode 100644 index 0000000..3170b40 --- /dev/null +++ b/ts/src/token.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + decodeToken, + isTokenExpired, + getTokenRemainingSeconds, + getTokenAgeSeconds, + shouldRefreshToken, + getDisplayName, + isValidDid, + isValidHandle, +} from './token'; + +// Helper to create a valid token payload +function createPayload(overrides = {}) { + const now = Math.floor(Date.now() / 1000); + return { + did: 'did:plc:abc123', + handle: 'alice.bsky.social', + user_id: 1, + app_id: 'testapp', + iat: now, + exp: now + 3600, // 1 hour + nonce: 'test-nonce', + ...overrides, + }; +} + +// Helper to encode a payload as a token (without signature) +function encodePayload(payload: object): string { + const json = JSON.stringify(payload); + const b64 = Buffer.from(json).toString('base64url'); + return `${b64}.fake-signature`; +} + +describe('decodeToken', () => { + it('decodes a valid token', () => { + const payload = createPayload(); + const token = encodePayload(payload); + const decoded = decodeToken(token); + + expect(decoded).not.toBeNull(); + expect(decoded?.did).toBe(payload.did); + expect(decoded?.handle).toBe(payload.handle); + }); + + it('returns null for invalid format (no dot)', () => { + expect(decodeToken('invalid-token')).toBeNull(); + }); + + it('returns null for invalid format (too many dots)', () => { + expect(decodeToken('a.b.c')).toBeNull(); + }); + + it('returns null for invalid base64', () => { + expect(decodeToken('!!!invalid!!!.signature')).toBeNull(); + }); + + it('returns null for invalid JSON', () => { + const invalidJson = Buffer.from('not json').toString('base64url'); + expect(decodeToken(`${invalidJson}.signature`)).toBeNull(); + }); +}); + +describe('isTokenExpired', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns false for non-expired token', () => { + const now = 1700000000000; + vi.setSystemTime(now); + + const payload = createPayload({ + iat: 1700000000, + exp: 1700003600, // 1 hour from now + }); + + expect(isTokenExpired(payload)).toBe(false); + }); + + it('returns true for expired token', () => { + const now = 1700010000000; // Well past exp + vi.setSystemTime(now); + + const payload = createPayload({ + iat: 1700000000, + exp: 1700003600, + }); + + expect(isTokenExpired(payload)).toBe(true); + }); + + it('respects clock skew tolerance', () => { + const now = 1700003610000; // 10 seconds past exp + vi.setSystemTime(now); + + const payload = createPayload({ + iat: 1700000000, + exp: 1700003600, + }); + + // With default 30s skew, should not be expired + expect(isTokenExpired(payload, 30)).toBe(false); + + // With 0s skew, should be expired + expect(isTokenExpired(payload, 0)).toBe(true); + }); +}); + +describe('getTokenRemainingSeconds', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns correct remaining time', () => { + const now = 1700000000000; + vi.setSystemTime(now); + + const payload = createPayload({ + exp: 1700003600, // 3600 seconds from now + }); + + expect(getTokenRemainingSeconds(payload)).toBe(3600); + }); + + it('returns 0 for expired token', () => { + const now = 1700010000000; + vi.setSystemTime(now); + + const payload = createPayload({ + exp: 1700003600, + }); + + expect(getTokenRemainingSeconds(payload)).toBe(0); + }); +}); + +describe('getTokenAgeSeconds', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns correct age', () => { + const now = 1700001800000; // 1800 seconds after iat + vi.setSystemTime(now); + + const payload = createPayload({ + iat: 1700000000, + }); + + expect(getTokenAgeSeconds(payload)).toBe(1800); + }); +}); + +describe('shouldRefreshToken', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns false when plenty of time remaining', () => { + const now = 1700000000000; + vi.setSystemTime(now); + + const payload = createPayload({ + exp: 1700003600, // 3600 seconds remaining + }); + + expect(shouldRefreshToken(payload, 300)).toBe(false); + }); + + it('returns true when below threshold', () => { + const now = 1700003400000; // 200 seconds remaining + vi.setSystemTime(now); + + const payload = createPayload({ + exp: 1700003600, + }); + + expect(shouldRefreshToken(payload, 300)).toBe(true); + }); +}); + +describe('getDisplayName', () => { + it('extracts username from handle', () => { + expect(getDisplayName('alice.bsky.social')).toBe('alice'); + }); + + it('handles single-part handle', () => { + expect(getDisplayName('alice')).toBe('alice'); + }); + + it('handles empty string', () => { + expect(getDisplayName('')).toBe(''); + }); +}); + +describe('isValidDid', () => { + it('accepts valid did:plc', () => { + expect(isValidDid('did:plc:abc123')).toBe(true); + }); + + it('accepts valid did:web', () => { + expect(isValidDid('did:web:example.com')).toBe(true); + }); + + it('rejects empty string', () => { + expect(isValidDid('')).toBe(false); + }); + + it('rejects non-did string', () => { + expect(isValidDid('not-a-did')).toBe(false); + }); + + it('rejects did with missing parts', () => { + expect(isValidDid('did:plc')).toBe(false); + expect(isValidDid('did:')).toBe(false); + }); + + it('rejects oversized did', () => { + const longDid = 'did:plc:' + 'a'.repeat(600); + expect(isValidDid(longDid)).toBe(false); + }); +}); + +describe('isValidHandle', () => { + it('accepts valid handle', () => { + expect(isValidHandle('alice.bsky.social')).toBe(true); + }); + + it('accepts short TLD', () => { + expect(isValidHandle('user.co')).toBe(true); + }); + + it('rejects empty string', () => { + expect(isValidHandle('')).toBe(false); + }); + + it('rejects handle without dot', () => { + expect(isValidHandle('alice')).toBe(false); + }); + + it('rejects single-char TLD', () => { + expect(isValidHandle('user.a')).toBe(false); + }); + + it('rejects oversized handle', () => { + const longHandle = 'a'.repeat(200) + '.com'; + expect(isValidHandle(longHandle)).toBe(false); + }); + + it('rejects handle with empty segment', () => { + expect(isValidHandle('alice..social')).toBe(false); + expect(isValidHandle('.bsky.social')).toBe(false); + }); +}); diff --git a/ts/src/validation.test.ts b/ts/src/validation.test.ts new file mode 100644 index 0000000..939df0b --- /dev/null +++ b/ts/src/validation.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { + parseOAuthState, + isValidAppId, + requireHttpsInProduction, + validateGatewayUrl, + validateCallbackUrl, + isValidDid, + isValidHandle, +} from './validation'; + +describe('parseOAuthState', () => { + it('parses valid state object', () => { + const state = JSON.stringify({ returnTo: '/dashboard', nonce: 'abc123' }); + const parsed = parseOAuthState(state); + + expect(parsed).not.toBeNull(); + expect(parsed?.returnTo).toBe('/dashboard'); + expect(parsed?.nonce).toBe('abc123'); + }); + + it('returns null for non-string input', () => { + expect(parseOAuthState(null)).toBeNull(); + expect(parseOAuthState(undefined)).toBeNull(); + expect(parseOAuthState(123)).toBeNull(); + expect(parseOAuthState({})).toBeNull(); + }); + + it('returns null for invalid JSON', () => { + expect(parseOAuthState('not json')).toBeNull(); + expect(parseOAuthState('{invalid')).toBeNull(); + }); + + it('returns null for array', () => { + expect(parseOAuthState('[]')).toBeNull(); + expect(parseOAuthState('[1,2,3]')).toBeNull(); + }); + + it('returns null for oversized state', () => { + const huge = JSON.stringify({ data: 'x'.repeat(5000) }); + expect(parseOAuthState(huge)).toBeNull(); + }); + + it('returns null for deeply nested state', () => { + const deep = JSON.stringify({ a: { b: { c: { d: { e: 'too deep' } } } } }); + expect(parseOAuthState(deep)).toBeNull(); + }); + + it('returns null for non-string returnTo', () => { + expect(parseOAuthState(JSON.stringify({ returnTo: 123 }))).toBeNull(); + expect(parseOAuthState(JSON.stringify({ returnTo: {} }))).toBeNull(); + }); + + it('returns null for non-string nonce', () => { + expect(parseOAuthState(JSON.stringify({ nonce: 123 }))).toBeNull(); + }); + + it('returns null for dangerous returnTo schemes', () => { + expect(parseOAuthState(JSON.stringify({ returnTo: 'javascript:alert(1)' }))).toBeNull(); + expect(parseOAuthState(JSON.stringify({ returnTo: 'data:text/html,