|
1 | 1 | import { HttpMiddleware, HttpServerRequest, HttpServerResponse } from '@effect/platform' |
2 | | -import { Effect } from 'effect' |
| 2 | +import { Effect, Either } from 'effect' |
3 | 3 | import { timingSafeEqual } from 'node:crypto' |
4 | 4 | import { parseScopes } from '@sandchest/contract' |
5 | | -import { UnauthorizedError, formatApiError } from './errors.js' |
| 5 | +import { RateLimitedError, UnauthorizedError, formatApiError } from './errors.js' |
6 | 6 | import { AuthContext } from './context.js' |
7 | 7 | import { auth } from './auth.js' |
8 | 8 | import { loadEnv } from './env.js' |
9 | 9 |
|
| 10 | +type BetterAuthErrorShape = { |
| 11 | + statusCode?: number |
| 12 | + headers?: Record<string, string> |
| 13 | + body?: { |
| 14 | + code?: string |
| 15 | + message?: string |
| 16 | + details?: Record<string, unknown> |
| 17 | + } |
| 18 | +} |
| 19 | + |
| 20 | +function extractRetryAfterSeconds(error: BetterAuthErrorShape): number { |
| 21 | + const headerValue = error.headers?.['retry-after'] |
| 22 | + const headerSeconds = headerValue ? Number(headerValue) : NaN |
| 23 | + if (Number.isFinite(headerSeconds) && headerSeconds > 0) { |
| 24 | + return headerSeconds |
| 25 | + } |
| 26 | + |
| 27 | + const details = error.body?.details |
| 28 | + const candidates = [ |
| 29 | + details?.['retryAfter'], |
| 30 | + details?.['retry_after'], |
| 31 | + ] |
| 32 | + |
| 33 | + for (const value of candidates) { |
| 34 | + const seconds = typeof value === 'number' ? value : Number(value) |
| 35 | + if (Number.isFinite(seconds) && seconds > 0) { |
| 36 | + return seconds |
| 37 | + } |
| 38 | + } |
| 39 | + |
| 40 | + return 60 |
| 41 | +} |
| 42 | + |
| 43 | +export function normalizeApiKeyVerificationError(error: unknown) { |
| 44 | + const candidate = error as BetterAuthErrorShape | undefined |
| 45 | + const code = candidate?.body?.code |
| 46 | + const message = candidate?.body?.message |
| 47 | + |
| 48 | + if (code === 'RATE_LIMITED') { |
| 49 | + return new RateLimitedError({ |
| 50 | + message: message ?? 'Rate limit exceeded.', |
| 51 | + retryAfter: extractRetryAfterSeconds(candidate ?? {}), |
| 52 | + }) |
| 53 | + } |
| 54 | + |
| 55 | + return new UnauthorizedError({ message: 'Invalid API key' }) |
| 56 | +} |
| 57 | + |
10 | 58 | /** |
11 | 59 | * Generates a request ID (or propagates from X-Request-Id header) |
12 | 60 | * and attaches it to the response. |
@@ -59,12 +107,16 @@ export const withAuth = HttpMiddleware.make((app) => |
59 | 107 | const authHeader = request.headers['authorization'] |
60 | 108 | if (authHeader?.startsWith('Bearer ')) { |
61 | 109 | const key = authHeader.slice(7) |
62 | | - const result = yield* Effect.tryPromise({ |
| 110 | + const verification = yield* Effect.either(Effect.tryPromise({ |
63 | 111 | try: () => auth.api.verifyApiKey({ body: { key } }), |
64 | | - catch: () => new UnauthorizedError({ message: 'Invalid API key' }), |
65 | | - }).pipe( |
66 | | - Effect.catchAll(() => Effect.succeed(null)), |
67 | | - ) |
| 112 | + catch: normalizeApiKeyVerificationError, |
| 113 | + })) |
| 114 | + |
| 115 | + if (Either.isLeft(verification)) { |
| 116 | + return formatApiError(verification.left) |
| 117 | + } |
| 118 | + |
| 119 | + const result = verification.right |
68 | 120 |
|
69 | 121 | if (!result?.valid) { |
70 | 122 | return formatApiError(new UnauthorizedError({ message: 'Invalid API key' })) |
|
0 commit comments