| 1 | import { createHash, randomBytes } from 'node:crypto'; |
| 2 | |
| 3 | import { schema } |
| 4 | import { and, eq |
| 5 | |
| 6 | import type { Executor |
| 7 | |
| 8 | /** GitHub's lifetimes for expiring user tokens, in seconds. */ |
| 9 | export const ACCESS_TOKEN_EXPIRES_IN = |
| 10 | export const REFRESH_TOKEN_EXPIRES_IN = |
| 11 | |
| 12 | /** What a refresh token grants again: the user, the scopes they approved, and the key it was issued beside. */ |
| 13 | export type RefreshGrant |
| 14 | userId: string |
| 15 | scopes: string |
| 16 | accessKeyId: string |
| 17 | }; |
| 18 | |
| 19 | export const newRefreshToken = |
| 20 | `ghost_rt_${randomBytes(32) |
| 21 | |
| 22 | export const hashRefreshToken = |
| 23 | createHash('sha256'). |
| 24 | |
| 25 | /** Stores a refresh token for the grant and returns it; only its hash is kept. */ |
| 26 | export async function issueRefreshToken |
| 27 | db: Executor |
| 28 | clientId: string, |
| 29 | grant: RefreshGrant |
| 30 | ): Promise<string |
| 31 | const token = |
| 32 | await db.insert |
| 33 | ...grant, |
| 34 | clientId, |
| 35 | tokenHash: hashRefreshToken(token |
| 36 | expiresAt: new Date |
| 37 | }); |
| 38 | return token; |
| 39 | } |
| 40 | |
| 41 | /** Serializes a refresh and a revoke of one user's grant to an app, so a revoke never misses the pair a refresh is minting. Held until the transaction ends. */ |
| 42 | export async function lockGrant |
| 43 | tx: Executor |
| 44 | clientId: string, |
| 45 | userId: string, |
| 46 | ) { |
| 47 | await tx.execute |
| 48 | sql`select pg_advisory_xact_lock(hashtext(${clientId} |
| 49 | ); |
| 50 | } |
| 51 | |
| 52 | /** Deletes the app's refresh token under the grant's lock and returns what it granted, or null when it is unknown, another app's, or expired. Run inside a transaction: the lock lasts until the new pair is stored. */ |
| 53 | export async function spendRefreshToken |
| 54 | tx: Executor |
| 55 | clientId: string, |
| 56 | token: string, |
| 57 | ): Promise<RefreshGrant |
| 58 | const table = |
| 59 | const match = |
| 60 | eq(table.tokenHash |
| 61 | eq(table.clientId |
| 62 | ); |
| 63 | const [owner] |
| 64 | .select({ userId: |
| 65 | .from(table) |
| 66 | .where(match); |
| 67 | if (!owner) |
| 68 | await lockGrant(tx |
| 69 | // A concurrent refresh or revoke may have taken the row while this one waited for the lock. |
| 70 | const [spent] |
| 71 | userId: table.userId |
| 72 | scopes: table.scopes |
| 73 | accessKeyId: table.accessKeyId |
| 74 | expiresAt: table.expiresAt |
| 75 | }); |
| 76 | if (!spent) |
| 77 | const { expiresAt, |
| 78 | return expiresAt <= |
| 79 | } |