Files
HealthLog/src/lib/auth/issue-token.ts
T
Marc-André BombeckandClaude Opus 4.7 f83a68d78e feat(auth): optional Bearer token issuance on login for headless clients
When a request to `/api/auth/login` or `/api/auth/passkey/login-verify`
carries `X-Client-Type: native` (or a `User-Agent` matching `HealthLog-iOS`),
the response now also contains a 90-day `hlk_*` Bearer token alongside
the user object. Browser sessions continue to receive the cookie only —
no extra payload, no behaviour change for them.

Token issuance is gated by header detection in `src/lib/auth/issue-token.ts`
and re-uses the existing `ApiToken` table + HMAC pipeline; nothing new on
the storage side. Audit log records `auth.token.autoissue.native` so each
auto-issued token is traceable to its login event.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:53:07 +02:00

76 lines
2.1 KiB
TypeScript

/**
* Helper for issuing an `ApiToken` for a user — currently used by the
* native-client auto-login flow (login + passkey login-verify) so the iOS
* app can talk to bearer-protected routes without juggling the session
* cookie. The raw `hlk_<hex>` value is returned exactly once.
*/
import { randomBytes } from "node:crypto";
import { prisma } from "@/lib/db";
import { hashToken } from "@/lib/auth/hmac";
export interface IssuedToken {
token: string;
expiresAt: Date;
tokenId: string;
name: string;
}
export interface IssueTokenOptions {
userId: string;
/** Token row name (also surfaced to the user under /api/tokens). */
name: string;
/** Default permissions. `["*"]` = unrestricted (matches cookie scope). */
permissions?: string[];
/** Lifetime in days. */
expiresInDays?: number;
}
/**
* Generate a new `hlk_<64 hex>` token, store its hash, and return the raw
* value to the caller. The plaintext token is never persisted and must
* never be logged.
*/
export async function issueApiToken(
opts: IssueTokenOptions,
): Promise<IssuedToken> {
const rawToken = `hlk_${randomBytes(32).toString("hex")}`;
const tokenHashValue = hashToken(rawToken);
const expiresAt = new Date(
Date.now() + (opts.expiresInDays ?? 90) * 24 * 60 * 60 * 1000,
);
const created = await prisma.apiToken.create({
data: {
userId: opts.userId,
name: opts.name,
tokenHash: tokenHashValue,
permissions: opts.permissions ?? ["*"],
expiresAt,
},
select: { id: true },
});
return {
token: rawToken,
expiresAt,
tokenId: created.id,
name: opts.name,
};
}
/**
* Detect whether the request is from a native client and should receive a
* bearer token in the login response. Two opt-in signals:
* - `X-Client-Type: native`
* - `User-Agent: HealthLog-iOS/...`
*/
export function isNativeClientRequest(headers: Headers): boolean {
const xClient = headers.get("x-client-type");
if (xClient && xClient.toLowerCase() === "native") return true;
const ua = headers.get("user-agent") ?? "";
if (ua.startsWith("HealthLog-iOS")) return true;
return false;
}