ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
16
fork

Configure Feed

Select the types of activity you want to include in your feed.

test(api): add auth route integration tests

- oauth cleint metadata, loopback v production
- jwks endpoint validation
- get, logout session
- oauth flow parameter validation
- validate input, errors, session lifetime

byarielm.fyi 28526743 4446d1d7

verified
+288
+288
packages/api/__tests__/routes/auth.test.ts
··· 1 + /** 2 + * Auth API Integration Tests 3 + * 4 + * Tests OAuth endpoints, session management, and authentication flows. 5 + */ 6 + 7 + import { describe, it, expect, beforeAll, afterAll } from 'vitest'; 8 + import { 9 + request, 10 + authRequest, 11 + requestWithSession, 12 + parseResponse, 13 + } from '../helpers'; 14 + import { 15 + createTestSession, 16 + createExpiredTestSession, 17 + cleanupAllTestSessions, 18 + } from '../fixtures'; 19 + 20 + describe('Auth API', () => { 21 + let validSession: string; 22 + 23 + beforeAll(async () => { 24 + validSession = await createTestSession('standard'); 25 + }); 26 + 27 + afterAll(async () => { 28 + await cleanupAllTestSessions(); 29 + }); 30 + 31 + describe('GET /api/auth/client-metadata.json', () => { 32 + it('returns loopback client metadata for localhost', async () => { 33 + const res = await request('/api/auth/client-metadata.json', { 34 + headers: { 35 + host: '127.0.0.1:8888', 36 + }, 37 + }); 38 + expect(res.status).toBe(200); 39 + 40 + const body = await parseResponse(res); 41 + expect(body.client_id).toBe('http://127.0.0.1:8888'); 42 + expect(body.client_name).toBe('ATlast (Local Dev)'); 43 + expect(body.client_uri).toBe('http://127.0.0.1:8888'); 44 + expect(body.redirect_uris).toEqual([ 45 + 'http://127.0.0.1:8888/api/auth/oauth-callback', 46 + ]); 47 + expect(body.application_type).toBe('web'); 48 + expect(body.token_endpoint_auth_method).toBe('none'); 49 + expect(body.dpop_bound_access_tokens).toBe(true); 50 + expect(body.grant_types).toEqual(['authorization_code', 'refresh_token']); 51 + expect(body.response_types).toEqual(['code']); 52 + }); 53 + 54 + it('returns production client metadata for non-localhost', async () => { 55 + const res = await request('/api/auth/client-metadata.json', { 56 + headers: { 57 + host: 'atlast.app', 58 + }, 59 + }); 60 + expect(res.status).toBe(200); 61 + 62 + const body = await parseResponse(res); 63 + expect(body.client_id).toBe( 64 + 'https://atlast.app/api/auth/client-metadata.json', 65 + ); 66 + expect(body.client_name).toBe('ATlast'); 67 + expect(body.client_uri).toBe('https://atlast.app'); 68 + expect(body.redirect_uris).toEqual([ 69 + 'https://atlast.app/api/auth/oauth-callback', 70 + ]); 71 + expect(body.jwks_uri).toBe('https://atlast.app/api/auth/jwks'); 72 + expect(body.logo_uri).toBe('https://atlast.app/favicon.svg'); 73 + expect(body.token_endpoint_auth_method).toBe('private_key_jwt'); 74 + expect(body.token_endpoint_auth_signing_alg).toBe('ES256'); 75 + expect(body.dpop_bound_access_tokens).toBe(true); 76 + }); 77 + 78 + it('respects x-forwarded-host header', async () => { 79 + const res = await request('/api/auth/client-metadata.json', { 80 + headers: { 81 + host: 'localhost:8888', 82 + 'x-forwarded-host': 'atlast.app', 83 + }, 84 + }); 85 + expect(res.status).toBe(200); 86 + 87 + const body = await parseResponse(res); 88 + expect(body.client_id).toBe( 89 + 'https://atlast.app/api/auth/client-metadata.json', 90 + ); 91 + }); 92 + 93 + it('returns 400 without host header', async () => { 94 + const res = await request('/api/auth/client-metadata.json'); 95 + expect(res.status).toBe(400); 96 + 97 + const body = await parseResponse(res); 98 + expect(body.error).toBe('Missing host header'); 99 + }); 100 + }); 101 + 102 + describe('GET /api/auth/jwks', () => { 103 + it('returns public JWK set', async () => { 104 + const res = await request('/api/auth/jwks'); 105 + expect(res.status).toBe(200); 106 + 107 + const body = await parseResponse(res); 108 + expect(body.keys).toBeDefined(); 109 + expect(Array.isArray(body.keys)).toBe(true); 110 + expect(body.keys.length).toBeGreaterThan(0); 111 + 112 + const jwk = body.keys[0]; 113 + expect(jwk.kty).toBe('EC'); 114 + expect(jwk.crv).toBe('P-256'); 115 + expect(jwk.use).toBe('sig'); 116 + expect(jwk.alg).toBe('ES256'); 117 + expect(jwk).toHaveProperty('kid'); 118 + expect(jwk).toHaveProperty('x'); 119 + expect(jwk).toHaveProperty('y'); 120 + }); 121 + 122 + it('includes cache headers', async () => { 123 + const res = await request('/api/auth/jwks'); 124 + expect(res.headers.get('cache-control')).toBe('public, max-age=3600'); 125 + }); 126 + }); 127 + 128 + describe('GET /api/auth/session', () => { 129 + it('returns session data with valid session cookie', async () => { 130 + const res = await requestWithSession('/api/auth/session', validSession); 131 + expect(res.status).toBe(200); 132 + 133 + const body = await parseResponse(res); 134 + expect(body.success).toBe(true); 135 + expect(body.data).toHaveProperty('did'); 136 + expect(body.data.did).toMatch(/^did:/); 137 + expect(body.data).toHaveProperty('sessionId'); 138 + }); 139 + 140 + it('returns session data with session query parameter', async () => { 141 + const res = await request(`/api/auth/session?session=${validSession}`); 142 + expect(res.status).toBe(200); 143 + 144 + const body = await parseResponse(res); 145 + expect(body.success).toBe(true); 146 + expect(body.data).toHaveProperty('did'); 147 + }); 148 + 149 + it('returns 401 without session', async () => { 150 + const res = await request('/api/auth/session'); 151 + expect(res.status).toBe(401); 152 + 153 + const body = await parseResponse(res); 154 + expect(body.success).toBe(false); 155 + expect(body.error).toBe('No session cookie'); 156 + }); 157 + 158 + it('returns 401 with expired session', async () => { 159 + const expiredSession = await createExpiredTestSession(); 160 + const res = await requestWithSession('/api/auth/session', expiredSession); 161 + expect(res.status).toBe(401); 162 + 163 + const body = await parseResponse(res); 164 + expect(body.success).toBe(false); 165 + expect(body.error).toBe('Invalid or expired session'); 166 + }); 167 + 168 + it('returns 401 with invalid session format', async () => { 169 + const res = await requestWithSession( 170 + '/api/auth/session', 171 + 'not-a-valid-session', 172 + ); 173 + expect(res.status).toBe(401); 174 + 175 + const body = await parseResponse(res); 176 + expect(body.success).toBe(false); 177 + }); 178 + }); 179 + 180 + describe('POST /api/auth/logout', () => { 181 + it('clears session and returns success', async () => { 182 + // Create a fresh session for this test 183 + const sessionToLogout = await createTestSession('standard'); 184 + 185 + // Verify session exists 186 + const checkRes = await requestWithSession( 187 + '/api/auth/session', 188 + sessionToLogout, 189 + ); 190 + expect(checkRes.status).toBe(200); 191 + 192 + // Logout 193 + const logoutRes = await requestWithSession( 194 + '/api/auth/logout', 195 + sessionToLogout, 196 + { method: 'POST' }, 197 + ); 198 + expect(logoutRes.status).toBe(200); 199 + 200 + const body = await parseResponse(logoutRes); 201 + expect(body.success).toBe(true); 202 + 203 + // Verify session is now invalid 204 + const afterLogoutRes = await requestWithSession( 205 + '/api/auth/session', 206 + sessionToLogout, 207 + ); 208 + expect(afterLogoutRes.status).toBe(401); 209 + }); 210 + 211 + it('succeeds even without existing session', async () => { 212 + const res = await request('/api/auth/logout', { 213 + method: 'POST', 214 + }); 215 + expect(res.status).toBe(200); 216 + 217 + const body = await parseResponse(res); 218 + expect(body.success).toBe(true); 219 + }); 220 + 221 + it('clears session cookie', async () => { 222 + const sessionToLogout = await createTestSession('standard'); 223 + 224 + const res = await requestWithSession( 225 + '/api/auth/logout', 226 + sessionToLogout, 227 + { method: 'POST' }, 228 + ); 229 + 230 + // Check that Set-Cookie header is present with maxAge=0 231 + const setCookie = res.headers.get('set-cookie'); 232 + expect(setCookie).toBeTruthy(); 233 + expect(setCookie).toContain('Max-Age=0'); 234 + }); 235 + }); 236 + 237 + describe('POST /api/auth/oauth-start', () => { 238 + it('returns 400 without login_hint', async () => { 239 + const res = await request('/api/auth/oauth-start', { 240 + method: 'POST', 241 + body: JSON.stringify({}), 242 + }); 243 + expect(res.status).toBe(400); 244 + 245 + const body = await parseResponse(res); 246 + expect(body.error).toContain('login_hint'); 247 + }); 248 + 249 + it('returns 400 with empty login_hint', async () => { 250 + const res = await request('/api/auth/oauth-start', { 251 + method: 'POST', 252 + body: JSON.stringify({ login_hint: '' }), 253 + }); 254 + expect(res.status).toBe(400); 255 + }); 256 + 257 + // Note: Full OAuth flow testing requires mocking the OAuth client 258 + // which is complex. These tests verify input validation. 259 + // Real OAuth flow should be tested manually or with E2E tests. 260 + }); 261 + 262 + describe('GET /api/auth/oauth-callback', () => { 263 + it('redirects with error when missing code parameter', async () => { 264 + const res = await request('/api/auth/oauth-callback?state=test-state', { 265 + redirect: 'manual', 266 + }); 267 + 268 + // Should redirect with error 269 + expect(res.status).toBe(302); 270 + const location = res.headers.get('location'); 271 + expect(location).toContain('error=Missing OAuth parameters'); 272 + }); 273 + 274 + it('redirects with error when missing state parameter', async () => { 275 + const res = await request('/api/auth/oauth-callback?code=test-code', { 276 + redirect: 'manual', 277 + }); 278 + 279 + expect(res.status).toBe(302); 280 + const location = res.headers.get('location'); 281 + expect(location).toContain('error=Missing OAuth parameters'); 282 + }); 283 + 284 + // Note: Full OAuth callback testing requires mocking the entire OAuth flow 285 + // These tests verify parameter validation. Complete flow should be tested 286 + // manually or with E2E tests that include real OAuth providers. 287 + }); 288 + });