mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
clean up auth and platform types
This commit is contained in:
+17
-30
@@ -1,21 +1,22 @@
|
||||
/**
|
||||
* Refresh-token storage on the shared `recflare` D1 database (owned by the `auth`
|
||||
* worker, migration 0003). Only a SHA-256 hash of each token is stored — never the
|
||||
* raw value — alongside the account + platform needed to re-mint an access token,
|
||||
* and an absolute expiry. Tokens are single-use: redeeming one deletes it, so a
|
||||
* fresh token is issued each refresh (rotation) and a replayed token stops working.
|
||||
* raw value — alongside the account it logs in and an absolute expiry. Tokens are
|
||||
* single-use: redeeming one deletes it, so a fresh token is issued each refresh
|
||||
* (rotation) and a replayed token stops working.
|
||||
*
|
||||
* The platform identity is NOT kept here (dropped in 0006); a refreshed token takes
|
||||
* it from the account, which is where the bound identity actually lives.
|
||||
*/
|
||||
|
||||
/** Refresh tokens live this long (s) before the client must log in again. */
|
||||
export const REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60 // 30 days
|
||||
|
||||
/** Schema DDL (mirror of migrations/0003_refresh_tokens.sql). */
|
||||
/** Schema DDL (mirror of migrations/0003_refresh_tokens.sql + 0006). */
|
||||
export const REFRESH_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)`,
|
||||
@@ -23,13 +24,6 @@ export const REFRESH_SCHEMA_DDL: string[] = [
|
||||
`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens (expires_at)`,
|
||||
]
|
||||
|
||||
/** The login context needed to re-mint an access token from a refresh token. */
|
||||
export interface RefreshContext {
|
||||
accountId: number
|
||||
platform: string
|
||||
platformId: string
|
||||
}
|
||||
|
||||
/** SHA-256 hex of the token. Tokens are high-entropy random, so no salt is needed. */
|
||||
async function hashToken(token: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token))
|
||||
@@ -37,47 +31,40 @@ async function hashToken(token: string): Promise<string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint and persist a new refresh token for the given login, returning the raw
|
||||
* Mint and persist a new refresh token for the given account, returning the raw
|
||||
* token — the only moment it exists in plaintext (only its hash is stored).
|
||||
*/
|
||||
export async function issueRefreshToken(db: D1Database, ctx: RefreshContext): Promise<string> {
|
||||
export async function issueRefreshToken(db: D1Database, accountId: number): Promise<string> {
|
||||
const token = `${crypto.randomUUID()}`
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO refresh_tokens (token_hash, account_id, platform, platform_id, created_at, expires_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)`
|
||||
)
|
||||
.bind(
|
||||
await hashToken(token),
|
||||
ctx.accountId,
|
||||
ctx.platform,
|
||||
ctx.platformId,
|
||||
now,
|
||||
now + REFRESH_TTL_SECONDS
|
||||
`INSERT INTO refresh_tokens (token_hash, account_id, created_at, expires_at)
|
||||
VALUES (?1, ?2, ?3, ?4)`
|
||||
)
|
||||
.bind(await hashToken(token), accountId, now, now + REFRESH_TTL_SECONDS)
|
||||
.run()
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem a refresh token: if it exists and hasn't expired, delete it (single-use
|
||||
* rotation) and return its login context; otherwise return null. The delete is
|
||||
* rotation) and return the account it logs in; otherwise return null. The delete is
|
||||
* atomic (`DELETE ... RETURNING`), so a token can't be redeemed twice — a
|
||||
* concurrent second attempt finds no row. An expired token is deleted and rejected.
|
||||
*/
|
||||
export async function consumeRefreshToken(
|
||||
db: D1Database,
|
||||
token: string
|
||||
): Promise<RefreshContext | null> {
|
||||
): Promise<number | null> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const row = await db
|
||||
.prepare(
|
||||
`DELETE FROM refresh_tokens WHERE token_hash = ?1
|
||||
RETURNING account_id AS accountId, platform, platform_id AS platformId, expires_at AS expiresAt`
|
||||
RETURNING account_id AS accountId, expires_at AS expiresAt`
|
||||
)
|
||||
.bind(await hashToken(token))
|
||||
.first<{ accountId: number; platform: string; platformId: string; expiresAt: number }>()
|
||||
.first<{ accountId: number; expiresAt: number }>()
|
||||
if (!row || row.expiresAt < now) return null
|
||||
return { accountId: row.accountId, platform: row.platform, platformId: row.platformId }
|
||||
return row.accountId
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user