also looking at this
A read-scoped project key, so a Stacks dashboard can read logs without a person's credentials
#15
9 files
+537
-17
| @@ -14,6 +14,8 @@ | ||
| 14 | 14 | */ |
| 15 | 15 | import { Auth } from '@stacksjs/auth' |
| 16 | 16 | import { config } from '@stacksjs/config' |
| 17 | import { db } from '@stacksjs/database' | |
| 18 | import { authorizeRead, looksLikeReadKey, shouldRecordUse } from './read-key' | |
| 17 | 19 | |
| 18 | 20 | export async function userFromRequest(request: any): Promise<any | null> { |
| 19 | 21 | const authHeader = request.headers?.get?.('authorization') ?? '' |
| @@ -37,3 +39,86 @@ export async function userFromRequest(request: any): Promise<any | null> { | ||
| 37 | 39 | return null |
| 38 | 40 | } |
| 39 | 41 | } |
| 42 | ||
| 43 | /** | |
| 44 | * The bearer a request presents, whatever carries it. | |
| 45 | * | |
| 46 | * Split out of `userFromRequest` so a read key can be recognised before it is | |
| 47 | * sent anywhere near `Auth.getUserFromToken`: the two credentials arrive in | |
| 48 | * the same header and only their shape tells them apart. | |
| 49 | */ | |
| 50 | function bearerFrom(request: any): string { | |
| 51 | const authHeader = request.headers?.get?.('authorization') ?? '' | |
| 52 | const header = request.bearerToken?.() ?? authHeader.replace(/^Bearer\s+/i, '') | |
| 53 | if (header) | |
| 54 | return header | |
| 55 | const cookie = request.headers?.get?.('cookie') ?? '' | |
| 56 | const name = (config.auth?.defaultTokenName || 'auth-token').replace(/[.*+?^${}()|[\]\\]/g, '\\$&') | |
| 57 | const m = cookie.match(new RegExp(`(?:^|;)\\s*${name}=([^;]+)`)) | |
| 58 | return m ? decodeURIComponent(m[1]) : '' | |
| 59 | } | |
| 60 | ||
| 61 | export type ProjectReadAccess = | |
| 62 | | { ok: true, as: 'user', user: any } | |
| 63 | | { ok: true, as: 'key' } | |
| 64 | | { ok: false, status: 401 | 403 | 404, error: string } | |
| 65 | ||
| 66 | /** | |
| 67 | * Who may read this project: the person signed in, or a key issued for it. | |
| 68 | * | |
| 69 | * Order matters. A read key is checked first when one is presented, because a | |
| 70 | * key is scoped to a single project and answering it does not need a user | |
| 71 | * lookup at all. Anything that is not shaped like a read key falls through to | |
| 72 | * the existing user path unchanged, so the dashboard's cookie and a person's | |
| 73 | * bearer token behave exactly as they did. | |
| 74 | * | |
| 75 | * A key for a different project answers 404 rather than 403, matching what | |
| 76 | * `ownsProject` already does: a caller who cannot read a project should not be | |
| 77 | * able to learn that it exists. | |
| 78 | */ | |
| 79 | export async function projectReadAccess(request: any, projectId: string, ownsProject: (user: any, id: string) => Promise<boolean>): Promise<ProjectReadAccess> { | |
| 80 | const presented = bearerFrom(request) | |
| 81 | ||
| 82 | if (looksLikeReadKey(presented)) { | |
| 83 | // db.unsafe answers an untyped row, so the columns are narrowed here rather | |
| 84 | // than asserted: authorizeRead decides who may read a project and should be | |
| 85 | // reading values it can see the shape of. | |
| 86 | const found = (await db.unsafe( | |
| 87 | 'SELECT id, read_key, read_key_last_used_at, is_active FROM projects WHERE id = $1 LIMIT 1', | |
| 88 | [String(projectId)], | |
| 89 | ))?.[0] | |
| 90 | const row = found | |
| 91 | ? { | |
| 92 | id: String(found.id), | |
| 93 | read_key: found.read_key == null ? null : String(found.read_key), | |
| 94 | read_key_last_used_at: found.read_key_last_used_at == null ? null : String(found.read_key_last_used_at), | |
| 95 | is_active: found.is_active as boolean | number | null, | |
| 96 | } | |
| 97 | : null | |
| 98 | const verdict = authorizeRead(row, presented) | |
| 99 | // A key that is valid for some other project must not reveal that this one | |
| 100 | // exists, so its refusal is the same 404 an unknown id gets. | |
| 101 | if (!verdict.ok) | |
| 102 | return verdict.status === 401 ? { ok: false, status: 404, error: 'not found' } : verdict | |
| 103 | if (row) | |
| 104 | await recordReadKeyUse(row) | |
| 105 | return { ok: true, as: 'key' } | |
| 106 | } | |
| 107 | ||
| 108 | const user = await userFromRequest(request) | |
| 109 | if (!user) | |
| 110 | return { ok: false, status: 401, error: 'unauthorized' } | |
| 111 | if (!(await ownsProject(user, projectId))) | |
| 112 | return { ok: false, status: 404, error: 'not found' } | |
| 113 | return { ok: true, as: 'user', user } | |
| 114 | } | |
| 115 | ||
| 116 | /** Stamp the key as used, at most once a minute — see LAST_USED_RESOLUTION_MS. */ | |
| 117 | async function recordReadKeyUse(project: { id: string, read_key_last_used_at?: string | null }): Promise<void> { | |
| 118 | const now = new Date() | |
| 119 | if (!shouldRecordUse(project.read_key_last_used_at, now)) | |
| 120 | return | |
| 121 | // Never let the stamp fail a read: it is an aid to revoking a key, not part | |
| 122 | // of authorising one. | |
| 123 | await db.unsafe('UPDATE projects SET read_key_last_used_at = $1 WHERE id = $2', [now.toISOString(), String(project.id)]).catch(() => {}) | |
| 124 | } | |