mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-20 01:35:19 +03:00
feat: Sync to OSS repo
This commit is contained in:
39
packages/core/src/access-token/access-token.sql.ts
Normal file
39
packages/core/src/access-token/access-token.sql.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps, ulid, utc } from '../db/types.js';
|
||||
import { TeamTable } from '../team/team.sql.js';
|
||||
import { UserTable } from '../user/user.sql.js';
|
||||
|
||||
/**
|
||||
* A personal access token: a long-lived, revocable credential a user creates
|
||||
* for something that is not a browser — a nessh box registering itself, or a
|
||||
* script driving the API.
|
||||
*
|
||||
* Deliberately not a session JWT. A JWT is short-lived and cannot be revoked
|
||||
* without rotating signing keys for everyone, which makes it wrong for a
|
||||
* credential that sits in a config file on a machine for months.
|
||||
*/
|
||||
export const AccessTokenTable = pgTable(
|
||||
'access_token',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
ownerUserId: ulid('owner_user_id')
|
||||
.notNull()
|
||||
.references(() => UserTable.id, { onDelete: 'cascade' }),
|
||||
// Set to act within a team rather than as the user alone. The grant is
|
||||
// re-checked against live membership on every use, so losing the
|
||||
// membership disables the token without anyone remembering to revoke it.
|
||||
teamId: ulid('team_id').references(() => TeamTable.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
// Only the digest. The token is shown once, at creation.
|
||||
tokenHash: text('token_hash').notNull(),
|
||||
expiresAt: utc('expires_at'),
|
||||
lastUsed: utc('last_used')
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('access_token_hash_unique').on(t.tokenHash),
|
||||
index('access_token_owner_idx').on(t.ownerUserId),
|
||||
index('access_token_team_idx').on(t.teamId)
|
||||
]
|
||||
);
|
||||
185
packages/core/src/access-token/index.ts
Normal file
185
packages/core/src/access-token/index.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { AccessTokenTable } from './access-token.sql.js';
|
||||
|
||||
/**
|
||||
* Personal access tokens.
|
||||
*
|
||||
* The prefix is load-bearing: the API decides how to verify a bearer token by
|
||||
* looking at it, so a PAT never reaches JWT verification and a JWT never
|
||||
* reaches a database lookup. Without it every request would pay for both.
|
||||
*/
|
||||
export namespace AccessToken {
|
||||
export const PREFIX = 'pat_';
|
||||
|
||||
const SECRET_BYTES = 32;
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: z.string().meta({
|
||||
description: 'Unique identifier for the token record',
|
||||
example: Examples.AccessToken.id
|
||||
}),
|
||||
ownerUserId: z.string().meta({
|
||||
description: 'The user this token acts as',
|
||||
example: Examples.AccessToken.ownerUserId
|
||||
}),
|
||||
teamId: z.string().optional().nullable().meta({
|
||||
description: 'Team this token acts within, when it is team-scoped',
|
||||
example: Examples.AccessToken.teamId
|
||||
}),
|
||||
name: z.string().meta({
|
||||
description: 'What the token is for, so it can be recognised later',
|
||||
example: Examples.AccessToken.name
|
||||
}),
|
||||
expiresAt: z.iso.datetime().optional().nullable().meta({
|
||||
description: 'When the token stops working. Null means it does not expire.',
|
||||
example: Examples.AccessToken.expiresAt
|
||||
}),
|
||||
lastUsed: z.iso.datetime().optional().nullable().meta({
|
||||
description: 'When the token was last accepted',
|
||||
example: Examples.AccessToken.lastUsed
|
||||
})
|
||||
})
|
||||
.meta({
|
||||
ref: 'AccessToken',
|
||||
description: 'A long-lived, revocable credential for non-browser access',
|
||||
example: Examples.AccessToken
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
function generateToken(): string {
|
||||
return `${PREFIX}${randomBytes(SECRET_BYTES).toString('base64url')}`;
|
||||
}
|
||||
|
||||
async function hashToken(token: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token));
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** Cheap check that routes a bearer token to the right verifier. */
|
||||
export function looksLikeToken(bearer: string): boolean {
|
||||
return bearer.startsWith(PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a token. The value is returned here and nowhere else — only its
|
||||
* digest is stored, so a lost token is replaced rather than recovered.
|
||||
*/
|
||||
export const create = fn(
|
||||
Info.pick({ id: true, ownerUserId: true, teamId: true, name: true }).extend({
|
||||
expiresInDays: z.number().int().min(1).optional()
|
||||
}),
|
||||
async (input) => {
|
||||
const token = generateToken();
|
||||
await Database.use(async (tx) => {
|
||||
await tx.insert(AccessTokenTable).values({
|
||||
id: input.id,
|
||||
ownerUserId: input.ownerUserId,
|
||||
teamId: input.teamId ?? null,
|
||||
name: input.name,
|
||||
tokenHash: await hashToken(token),
|
||||
expiresAt: input.expiresInDays
|
||||
? sql`now() + interval '${sql.raw(String(input.expiresInDays))} days'`
|
||||
: null,
|
||||
lastUsed: null
|
||||
});
|
||||
});
|
||||
return { id: input.id, token };
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Resolve a token to its record, or `null`.
|
||||
*
|
||||
* Expiry is part of the query rather than a check afterwards: an expired
|
||||
* token and an unknown one are then indistinguishable to the caller, and
|
||||
* there is no branch left where a stale row could be accepted by mistake.
|
||||
*/
|
||||
export const authenticate = fn(z.string(), async (token) => {
|
||||
if (!looksLikeToken(token)) {
|
||||
return null;
|
||||
}
|
||||
const tokenHash = await hashToken(token);
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(AccessTokenTable)
|
||||
.where(
|
||||
and(
|
||||
eq(AccessTokenTable.tokenHash, tokenHash),
|
||||
isNull(AccessTokenTable.timeDeleted),
|
||||
sql`(${AccessTokenTable.expiresAt} is null or ${AccessTokenTable.expiresAt} > now())`
|
||||
)
|
||||
)
|
||||
.then((rows) => {
|
||||
const row = rows.at(0);
|
||||
// Serialized here so `tokenHash` never leaves this function.
|
||||
return row ? serialize(row) : null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export const touchLastUsed = fn(Info.shape.id, async (id) => {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.update(AccessTokenTable)
|
||||
.set({ lastUsed: sql`now()` })
|
||||
.where(eq(AccessTokenTable.id, id));
|
||||
});
|
||||
});
|
||||
|
||||
export const listByOwner = fn(Info.shape.ownerUserId, async (ownerUserId) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(AccessTokenTable)
|
||||
.where(
|
||||
and(eq(AccessTokenTable.ownerUserId, ownerUserId), isNull(AccessTokenTable.timeDeleted))
|
||||
)
|
||||
.orderBy(AccessTokenTable.timeCreated)
|
||||
.then((rows) => rows.map(serialize));
|
||||
});
|
||||
});
|
||||
|
||||
/** Revoke by id, but only for its owner — ids are guessable in shape. */
|
||||
export const revoke = fn(Info.pick({ id: true, ownerUserId: true }), async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.update(AccessTokenTable)
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(
|
||||
and(
|
||||
eq(AccessTokenTable.id, input.id),
|
||||
eq(AccessTokenTable.ownerUserId, input.ownerUserId),
|
||||
isNull(AccessTokenTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
.then((rows) => {
|
||||
const row = rows.at(0);
|
||||
return row ? serialize(row) : null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export function serialize(input: typeof AccessTokenTable.$inferSelect): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
ownerUserId: input.ownerUserId,
|
||||
teamId: input.teamId,
|
||||
name: input.name,
|
||||
expiresAt: input.expiresAt?.toISOString() ?? null,
|
||||
lastUsed: input.lastUsed?.toISOString() ?? null
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user