mirror of https://github.com/requarks/wiki
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
213 lines
6.1 KiB
213 lines
6.1 KiB
import crypto from 'node:crypto'
|
|
import { apiKeys as apiKeysTable, groups as groupsTable } from '../db/schema.ts'
|
|
import { desc, eq, inArray, sql } from 'drizzle-orm'
|
|
import { flatten, uniq } from 'es-toolkit/array'
|
|
import { epochSeconds, signJwt, verifyJwt } from '../helpers/jwt.ts'
|
|
|
|
/** The lifetimes the admin area offers, as durations the API accepts. */
|
|
export const KEY_EXPIRATIONS = {
|
|
'30d': { days: 30 },
|
|
'90d': { days: 90 },
|
|
'180d': { days: 180 },
|
|
'1y': { years: 1 },
|
|
'3y': { years: 3 }
|
|
} as const
|
|
|
|
export type KeyExpiration = keyof typeof KEY_EXPIRATIONS
|
|
|
|
/** An API key as exposed by the API. Never includes the token itself, which is not stored. */
|
|
export interface ApiKey {
|
|
id: string
|
|
name: string
|
|
keyShort: string
|
|
groups: string[]
|
|
expiration: Date
|
|
isRevoked: boolean
|
|
createdAt: Date
|
|
updatedAt: Date
|
|
}
|
|
|
|
/** What a verified key grants, resolved from its groups at request time. */
|
|
export interface ApiKeyIdentity {
|
|
id: string
|
|
permissions: string[]
|
|
}
|
|
|
|
/** Raised by `verify()` when a token is not usable, with a reason safe to return to the caller. */
|
|
export class ApiKeyError extends Error {}
|
|
|
|
const keySelection = {
|
|
id: apiKeysTable.id,
|
|
name: apiKeysTable.name,
|
|
keyShort: apiKeysTable.keyShort,
|
|
groups: apiKeysTable.groups,
|
|
expiration: apiKeysTable.expiration,
|
|
isRevoked: apiKeysTable.isRevoked,
|
|
createdAt: apiKeysTable.createdAt,
|
|
updatedAt: apiKeysTable.updatedAt
|
|
}
|
|
|
|
/**
|
|
* API Keys model
|
|
*
|
|
* A key is an RS256 JWT signed with the installation keypair, carrying the key row's ID and the
|
|
* groups it draws permissions from. The token is shown once at creation and never stored: the
|
|
* signature proves authenticity, and the row is consulted for revocation and expiry. Permissions are
|
|
* resolved from the groups on every request, so changing a group takes effect immediately.
|
|
*/
|
|
class ApiKeys {
|
|
/**
|
|
* The signing key, built from the passphrase-protected PEM in `config.auth.certs`
|
|
*/
|
|
private privateKey(): crypto.KeyObject {
|
|
return crypto.createPrivateKey({
|
|
key: WIKI.config.auth.certs.private,
|
|
passphrase: WIKI.config.auth.secret
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Every key, newest first. Revoked and expired keys are kept: the admin list shows their state.
|
|
*/
|
|
async getKeys(): Promise<ApiKey[]> {
|
|
const results = await WIKI.db
|
|
.select(keySelection)
|
|
.from(apiKeysTable)
|
|
.orderBy(desc(apiKeysTable.createdAt))
|
|
return results as ApiKey[]
|
|
}
|
|
|
|
/**
|
|
* Mint a new key.
|
|
*
|
|
* @returns The key row plus the token, which is the only time it exists outside the client
|
|
*/
|
|
async createKey({
|
|
name,
|
|
expiration,
|
|
groups
|
|
}: {
|
|
name: string
|
|
expiration: KeyExpiration
|
|
groups: string[]
|
|
}): Promise<{ id: string; key: string }> {
|
|
const id = crypto.randomUUID()
|
|
const expiresAt = Temporal.Now.zonedDateTimeISO('UTC')
|
|
.add(KEY_EXPIRATIONS[expiration])
|
|
.toInstant()
|
|
|
|
const key = signJwt(
|
|
{
|
|
// -> `api` marks the token as a key rather than a user token, so the two can never be
|
|
// confused should user tokens ever be signed with the same keypair
|
|
api: 1,
|
|
id,
|
|
grp: groups,
|
|
aud: WIKI.config.auth.audience,
|
|
iat: epochSeconds(),
|
|
exp: epochSeconds(expiresAt)
|
|
},
|
|
this.privateKey()
|
|
)
|
|
|
|
await WIKI.db.insert(apiKeysTable).values({
|
|
id,
|
|
name,
|
|
keyShort: key.slice(-8),
|
|
groups,
|
|
expiration: new Date(expiresAt.epochMilliseconds),
|
|
isRevoked: false
|
|
})
|
|
|
|
return { id, key }
|
|
}
|
|
|
|
/**
|
|
* A single key, or null if there is no such key
|
|
*/
|
|
async getKeyById(id: string): Promise<ApiKey | null> {
|
|
const results = await WIKI.db
|
|
.select(keySelection)
|
|
.from(apiKeysTable)
|
|
.where(eq(apiKeysTable.id, id))
|
|
.limit(1)
|
|
return (results[0] as ApiKey) ?? null
|
|
}
|
|
|
|
/**
|
|
* Revoke a key, permanently. Tokens already handed out stop working on the next request.
|
|
*
|
|
* @returns Whether a key was revoked
|
|
*/
|
|
async revokeKey(id: string): Promise<boolean> {
|
|
const result = await WIKI.db
|
|
.update(apiKeysTable)
|
|
.set({ isRevoked: true, updatedAt: sql`now()` })
|
|
.where(eq(apiKeysTable.id, id))
|
|
return (result.rowCount ?? 0) > 0
|
|
}
|
|
|
|
/**
|
|
* The union of the permissions held by the given groups.
|
|
*
|
|
* A group that no longer exists simply contributes nothing, so deleting a group narrows the keys
|
|
* pointing at it instead of breaking them.
|
|
*/
|
|
async resolvePermissions(groupIds: string[]): Promise<string[]> {
|
|
if (groupIds.length < 1) {
|
|
return []
|
|
}
|
|
const rows = await WIKI.db
|
|
.select({ permissions: groupsTable.permissions })
|
|
.from(groupsTable)
|
|
.where(inArray(groupsTable.id, groupIds))
|
|
return uniq(flatten(rows.map((r: any) => (r.permissions ?? []) as string[])))
|
|
}
|
|
|
|
/**
|
|
* Verify a bearer token and resolve what it grants.
|
|
*
|
|
* @throws ApiKeyError with a reason suitable for a 401 response
|
|
*/
|
|
async verify(token: string): Promise<ApiKeyIdentity> {
|
|
if (WIKI.config.api.isEnabled !== true) {
|
|
throw new ApiKeyError('The API is disabled.')
|
|
}
|
|
|
|
let claims
|
|
try {
|
|
claims = verifyJwt(token, WIKI.config.auth.certs.public, {
|
|
audience: WIKI.config.auth.audience
|
|
})
|
|
} catch (err: any) {
|
|
throw new ApiKeyError(err.message)
|
|
}
|
|
|
|
if (claims.api !== 1 || typeof claims.id !== 'string') {
|
|
throw new ApiKeyError('Token is not an API key.')
|
|
}
|
|
|
|
const key = await this.getKeyById(claims.id)
|
|
if (!key) {
|
|
throw new ApiKeyError('API key does not exist.')
|
|
}
|
|
if (key.isRevoked) {
|
|
throw new ApiKeyError('API key has been revoked.')
|
|
}
|
|
// -> The token carries its own expiry, but the row is what the admin area shows; a mismatch
|
|
// should fail closed rather than trust the token
|
|
if (Temporal.Instant.compare(key.expiration.toTemporalInstant(), Temporal.Now.instant()) <= 0) {
|
|
throw new ApiKeyError('API key has expired.')
|
|
}
|
|
|
|
return {
|
|
id: key.id,
|
|
permissions: await this.resolvePermissions(
|
|
Array.isArray(claims.grp) ? (claims.grp as string[]) : []
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
export const apiKeys = new ApiKeys()
|