feat(auth): add generic OIDC login with mandatory groups

This commit is contained in:
2026-08-17 05:44:10 +02:00
parent 202822f3ba
commit 4fe51cbeb1
20 changed files with 1144 additions and 40 deletions
+23
View File
@@ -14,6 +14,7 @@ import type { LoadedAuthConfig } from "./auth/types.js";
import { createCurrentLocalUserRegistryResolver, type LocalUserRegistry } from "./auth/local-registry.js";
import { AuthSessionOperationalError, createFileAuthSessionStore, type AuthSessionStore, type AuthSessionValidity } from "./auth/session-store.js";
import { registerAuthRoutes } from "./auth/routes.js";
import { createOidcProtocol, type OidcProtocol } from "./auth/oidc-client.js";
import { sessionRoutes } from "./routes/sessions.js";
import { sqlRoutes } from "./routes/sql.js";
import { metaRoutes, type ListModelsFn } from "./routes/meta.js";
@@ -48,6 +49,7 @@ export interface BuildAppDeps {
piManagement?: PiManagementService;
localUserRegistry?: LocalUserRegistry;
authSessionStore?: AuthSessionStore;
oidcProtocol?: OidcProtocol;
}
export interface AppWithAuthSessionStore extends FastifyInstance {
@@ -192,6 +194,26 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
currentAuthConfigRevision: () => loaded.revision,
currentLocalUser: (subject) => localUserForSnapshot(loaded, subject),
});
const resolveOidcProtocol = (loaded: LoadedAuthConfig): OidcProtocol | undefined => {
if (deps?.oidcProtocol) return deps.oidcProtocol;
if (loaded.value.mode !== "oidc") return undefined;
const clientSecret = process.env.THT_OIDC_CLIENT_SECRET;
if (typeof clientSecret !== "string" || clientSecret.length === 0 || clientSecret.length > 4096 || /\p{Cc}/u.test(clientSecret)) {
return undefined;
}
try {
return createOidcProtocol({
issuer: loaded.value.oidc.issuer,
clientId: loaded.value.oidc.clientId,
clientSecret,
callbackUrl: new URL("/api/auth/oidc/callback", loaded.value.publicUrl).href,
scopes: loaded.value.oidc.scopes,
groupsClaim: loaded.value.oidc.groupsClaim,
});
} catch {
return undefined;
}
};
const authSessionStore = deps?.authSessionStore ?? (config.authMode === "local" || config.authMode === "oidc"
? createFileAuthSessionStore(config.authStateRoot, {
currentAuthConfigRevision: () => {
@@ -251,6 +273,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
sessionStore: authSessionStore,
localUserRegistry: deps?.localUserRegistry,
resolveLocalUserRegistry,
resolveOidcProtocol,
});
sessionRoutes(app, {
mgr, tht: tht as ThtRunner, hub, getSettings, readiness, listModels, workspaceRegistry,
+294
View File
@@ -0,0 +1,294 @@
import {
authorizationCodeGrant,
buildAuthorizationUrl,
calculatePKCECodeChallenge,
customFetch,
discovery,
type Configuration,
} from "openid-client";
import { constants, createPublicKey, verify as verifySignature } from "node:crypto";
export interface OidcIdentity {
issuer: string;
subject: string;
displayName?: string;
groups: readonly string[];
tokenExpiresAt: Date;
}
export interface OidcProtocol {
authorizationUrl(input: { state: string; nonce: string; codeVerifier: string }): Promise<URL>;
callback(input: { currentUrl: URL; state: string; nonce: string; codeVerifier: string }): Promise<OidcIdentity>;
diagnose(signal: AbortSignal): Promise<void>;
verifyDeviceFlow?(signal: AbortSignal, present: (uri: string, code: string) => void): Promise<OidcIdentity>;
}
export class OidcProtocolError extends Error {
constructor() {
super("oidc_protocol_invalid");
}
}
export interface OidcProtocolOptions {
issuer: string;
clientId: string;
clientSecret: string;
callbackUrl: string;
scopes: readonly string[];
groupsClaim: string;
fetch?: typeof globalThis.fetch;
}
const MAX_GROUPS = 128;
const MAX_GROUP_LENGTH = 256;
const MAX_ID_TOKEN_LENGTH = 16 * 1024;
const MAX_JWKS_BYTES = 1024 * 1024;
const text = (value: unknown, maximum = 2048): value is string =>
typeof value === "string" && value.length > 0 && value.length <= maximum && !/\p{Cc}/u.test(value);
function configuredUrl(value: string): URL {
let url: URL;
try {
url = new URL(value);
} catch {
throw new OidcProtocolError();
}
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) throw new OidcProtocolError();
return url;
}
function httpsEndpoint(value: unknown): URL {
if (!text(value, 2048)) throw new OidcProtocolError();
let url: URL;
try {
url = new URL(value);
} catch {
throw new OidcProtocolError();
}
if (url.protocol !== "https:" || url.username || url.password || url.hash) throw new OidcProtocolError();
return url;
}
function groupsFromClaims(claims: Record<string, unknown>, name: string): string[] {
const indirect = claims._claim_names;
if ((indirect && typeof indirect === "object" && !Array.isArray(indirect)
&& Object.prototype.hasOwnProperty.call(indirect, name))
|| claims.hasgroups === true) throw new OidcProtocolError();
const raw = claims[name];
if (!Array.isArray(raw) || raw.length > MAX_GROUPS) throw new OidcProtocolError();
const groups: string[] = [];
const unique = new Set<string>();
for (const group of raw) {
if (!text(group, MAX_GROUP_LENGTH) || group.trim().length === 0 || unique.has(group)) throw new OidcProtocolError();
unique.add(group);
groups.push(group);
}
return groups;
}
function identityFromClaims(claims: Record<string, unknown>, options: OidcProtocolOptions): OidcIdentity {
if (claims.iss !== options.issuer || !text(claims.sub, 512)) throw new OidcProtocolError();
const audience = claims.aud;
if (!(audience === options.clientId || (Array.isArray(audience) && audience.includes(options.clientId)))) {
throw new OidcProtocolError();
}
if (typeof claims.exp !== "number" || !Number.isSafeInteger(claims.exp) || claims.exp * 1000 <= Date.now()) {
throw new OidcProtocolError();
}
const tokenExpiresAt = new Date(claims.exp * 1000);
if (Number.isNaN(tokenExpiresAt.getTime())) throw new OidcProtocolError();
return {
issuer: options.issuer,
subject: claims.sub,
...(text(claims.name, 256) ? { displayName: claims.name } : {}),
groups: groupsFromClaims(claims, options.groupsClaim),
tokenExpiresAt,
};
}
function jsonPart(part: string): Record<string, unknown> {
if (!/^[A-Za-z0-9_-]+$/.test(part) || part.length > MAX_ID_TOKEN_LENGTH) throw new OidcProtocolError();
try {
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(part, "base64url")));
if (!value || typeof value !== "object" || Array.isArray(value)) throw new OidcProtocolError();
return value as Record<string, unknown>;
} catch {
throw new OidcProtocolError();
}
}
function signatureAlgorithm(algorithm: string): {
digest: string | null;
pss?: boolean;
saltLength?: number;
ecdsaPartLength?: number;
} {
switch (algorithm) {
case "RS256": return { digest: "RSA-SHA256" };
case "RS384": return { digest: "RSA-SHA384" };
case "RS512": return { digest: "RSA-SHA512" };
case "PS256": return { digest: "sha256", pss: true, saltLength: 32 };
case "PS384": return { digest: "sha384", pss: true, saltLength: 48 };
case "PS512": return { digest: "sha512", pss: true, saltLength: 64 };
case "ES256": return { digest: "sha256", ecdsaPartLength: 32 };
case "ES384": return { digest: "sha384", ecdsaPartLength: 48 };
case "ES512": return { digest: "sha512", ecdsaPartLength: 66 };
case "EdDSA": return { digest: null };
default: throw new OidcProtocolError();
}
}
function derLength(length: number): Buffer {
if (length < 128) return Buffer.from([length]);
if (length < 256) return Buffer.from([0x81, length]);
throw new OidcProtocolError();
}
function derInteger(raw: Buffer): Buffer {
let start = 0;
while (start < raw.length - 1 && raw[start] === 0) start += 1;
let value = raw.subarray(start);
if ((value[0] & 0x80) !== 0) value = Buffer.concat([Buffer.from([0]), value]);
return Buffer.concat([Buffer.from([0x02]), derLength(value.length), value]);
}
function joseEcdsaSignatureToDer(signature: Buffer, partLength: number): Buffer {
if (signature.length !== partLength * 2) throw new OidcProtocolError();
const sequence = Buffer.concat([
derInteger(signature.subarray(0, partLength)),
derInteger(signature.subarray(partLength)),
]);
return Buffer.concat([Buffer.from([0x30]), derLength(sequence.length), sequence]);
}
async function verifyIdTokenSignature(
idToken: unknown,
config: Configuration,
options: OidcProtocolOptions,
): Promise<void> {
if (!text(idToken, MAX_ID_TOKEN_LENGTH)) throw new OidcProtocolError();
const [protectedPart, payloadPart, signaturePart, extra] = idToken.split(".");
if (!protectedPart || !payloadPart || !signaturePart || extra) throw new OidcProtocolError();
const header = jsonPart(protectedPart);
if (!text(header.alg, 16) || !text(header.kid, 256)) throw new OidcProtocolError();
const metadata = config.serverMetadata();
if (!Array.isArray(metadata.id_token_signing_alg_values_supported)
|| !metadata.id_token_signing_alg_values_supported.includes(header.alg)
|| !text(metadata.jwks_uri, 2048)) throw new OidcProtocolError();
const jwksUrl = httpsEndpoint(metadata.jwks_uri);
let response: Response;
try {
response = await (options.fetch ?? globalThis.fetch)(jwksUrl, {
headers: { accept: "application/json" }, redirect: "error",
});
if (!response.ok) throw new OidcProtocolError();
const body = await response.text();
if (Buffer.byteLength(body, "utf8") > MAX_JWKS_BYTES) throw new OidcProtocolError();
const parsed = JSON.parse(body) as { keys?: unknown };
if (!Array.isArray(parsed.keys) || parsed.keys.length === 0 || parsed.keys.length > 16) throw new OidcProtocolError();
const matching = parsed.keys.filter((key): key is Record<string, unknown> =>
Boolean(key) && typeof key === "object" && !Array.isArray(key) && key.kid === header.kid);
if (matching.length !== 1) throw new OidcProtocolError();
const key = matching[0];
if (key.use !== undefined && key.use !== "sig") throw new OidcProtocolError();
if (key.alg !== undefined && key.alg !== header.alg) throw new OidcProtocolError();
const algorithm = signatureAlgorithm(header.alg);
if (!/^[A-Za-z0-9_-]+$/.test(signaturePart)) throw new OidcProtocolError();
const signature = Buffer.from(signaturePart, "base64url");
if (signature.length === 0) throw new OidcProtocolError();
const publicKey = createPublicKey({ key: key as never, format: "jwk" });
const normalizedSignature = algorithm.ecdsaPartLength
? joseEcdsaSignatureToDer(signature, algorithm.ecdsaPartLength)
: signature;
const verified = algorithm.pss
? verifySignature(algorithm.digest, Buffer.from(`${protectedPart}.${payloadPart}`), {
key: publicKey, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: algorithm.saltLength,
}, normalizedSignature)
: verifySignature(algorithm.digest, Buffer.from(`${protectedPart}.${payloadPart}`), publicKey, normalizedSignature);
if (!verified) throw new OidcProtocolError();
} catch (error) {
if (error instanceof OidcProtocolError) throw error;
throw new OidcProtocolError();
}
}
export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol {
const issuerUrl = configuredUrl(options.issuer);
const callbackUrl = configuredUrl(options.callbackUrl);
if (!text(options.clientId, 512) || !text(options.clientSecret, 4096)
|| !text(options.groupsClaim, 128) || options.scopes.length === 0 || options.scopes.length > 16
|| options.scopes.some((scope) => !text(scope, 128))) throw new OidcProtocolError();
let discovered: Promise<Configuration> | undefined;
const configuration = async (): Promise<Configuration> => {
if (!discovered) {
discovered = (async () => {
try {
const config = await discovery(
issuerUrl,
options.clientId,
{ client_secret: options.clientSecret, redirect_uris: [callbackUrl.href], response_types: ["code"] },
undefined,
options.fetch ? { [customFetch]: options.fetch as never } : undefined,
);
const metadata = config.serverMetadata();
if (metadata.issuer !== options.issuer) throw new OidcProtocolError();
httpsEndpoint(metadata.authorization_endpoint);
httpsEndpoint(metadata.token_endpoint);
httpsEndpoint(metadata.jwks_uri);
return config;
} catch (error) {
if (error instanceof OidcProtocolError) throw error;
throw new OidcProtocolError();
}
})();
}
return await discovered;
};
return {
async authorizationUrl(input) {
try {
const challenge = await calculatePKCECodeChallenge(input.codeVerifier);
return buildAuthorizationUrl(await configuration(), {
response_type: "code",
redirect_uri: callbackUrl.href,
scope: options.scopes.join(" "),
state: input.state,
nonce: input.nonce,
code_challenge: challenge,
code_challenge_method: "S256",
});
} catch (error) {
if (error instanceof OidcProtocolError) throw error;
throw new OidcProtocolError();
}
},
async callback(input) {
if (input.currentUrl.origin !== callbackUrl.origin || input.currentUrl.pathname !== callbackUrl.pathname) {
throw new OidcProtocolError();
}
try {
const config = await configuration();
const tokens = await authorizationCodeGrant(config, input.currentUrl, {
expectedState: input.state,
expectedNonce: input.nonce,
pkceCodeVerifier: input.codeVerifier,
idTokenExpected: true,
});
await verifyIdTokenSignature(tokens.id_token, config, options);
const claims = tokens.claims();
if (!claims || Array.isArray(claims)) throw new OidcProtocolError();
return identityFromClaims(claims as Record<string, unknown>, options);
} catch (error) {
if (error instanceof OidcProtocolError) throw error;
throw new OidcProtocolError();
}
},
async diagnose(signal) {
signal.throwIfAborted();
await configuration();
signal.throwIfAborted();
},
};
}
+132 -8
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { randomBytes } from "node:crypto";
import type { AuthenticationConfigProvider, LoadedAuthConfig } from "./types.js";
import type { AuthenticationConfigProvider, LoadedAuthConfig, OidcAuthenticationConfig, Role } from "./types.js";
import type { LocalUserRecord, LocalUserRegistry } from "./local-registry.js";
import type { AuthSessionStore } from "./session-store.js";
import { rolesToPermissions } from "./config.js";
@@ -8,12 +8,15 @@ import { captureAuthConfigSnapshot, getPrincipal, requireExactOrigin, sessionCoo
import { requirePermission, isPrincipalContext } from "./authorization.js";
import { deriveCsrfToken } from "./csrf.js";
import { verifyWithDummy } from "./password.js";
import type { OidcProtocol } from "./oidc-client.js";
const TEN_MINUTES_MS = 10 * 60 * 1000;
const REMEMBER_COOKIE_SECONDS = 2_592_000;
const MAX_USERNAME_LENGTH = 64;
const MAX_PASSWORD_LENGTH = 1024;
const MAX_LIMIT_ENTRIES = 10_000;
const MAX_OIDC_CALLBACK_QUERY_LENGTH = 4096;
const OIDC_CALLBACK_PATH = "/api/auth/oidc/callback";
export interface AuthRouteDependencies {
authMode: "local" | "oidc" | "upstream" | "none" | "mock";
@@ -22,6 +25,8 @@ export interface AuthRouteDependencies {
/** Test-only compatibility seam; production resolves from each loaded config snapshot. */
localUserRegistry?: LocalUserRegistry;
resolveLocalUserRegistry?: (loaded: LoadedAuthConfig) => LocalUserRegistry | undefined;
oidcProtocol?: OidcProtocol;
resolveOidcProtocol?: (loaded: LoadedAuthConfig) => OidcProtocol | undefined;
}
interface LoginPayload {
@@ -118,7 +123,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
const snapshot = captureAuthConfigSnapshot(request, deps.authentication);
if (!snapshot) return unavailable(reply);
const mode = snapshot.value.mode;
return reply.send({ mode, localLogin: mode === "local", oidcLogin: false });
return reply.send({ mode, localLogin: mode === "local", oidcLogin: mode === "oidc" });
});
app.post("/auth/local/login", async (request, reply) => {
@@ -180,8 +185,83 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
}
});
app.get("/auth/oidc/login", async (_request, reply) => notImplemented(reply));
app.get("/auth/oidc/callback", async (_request, reply) => notImplemented(reply));
app.get("/auth/oidc/login", async (request, reply) => {
const loaded = captureAuthConfigSnapshot(request, deps.authentication);
const configured = currentOidcConfig(loaded, deps);
if (!configured || !deps.sessionStore) return unavailable(reply);
const nonce = randomOidcValue();
const codeVerifier = randomOidcValue();
try {
const created = await deps.sessionStore.createOidcState({
nonce,
codeVerifier,
returnTo: "/",
authConfigRevision: configured.loaded.revision,
issuer: configured.config.oidc.issuer,
});
try {
const location = await configured.protocol.authorizationUrl({ state: created.state, nonce, codeVerifier });
return reply.redirect(location.href);
} catch {
await deps.sessionStore.consumeOidcState(created.state).catch(() => undefined);
return unavailable(reply);
}
} catch {
return unavailable(reply);
}
});
app.get("/auth/oidc/callback", async (request, reply) => {
const loaded = captureAuthConfigSnapshot(request, deps.authentication);
if (!loaded || !deps.sessionStore) return oidcCallbackFailed(reply);
const callback = oidcCallbackUrl(request, loaded.value.publicUrl);
if (!callback) return oidcCallbackFailed(reply);
let state;
try {
state = await deps.sessionStore.consumeOidcState(callback.state);
} catch {
return oidcCallbackFailed(reply);
}
const configured = currentOidcConfig(loaded, deps);
if (!configured || !state || state.returnTo !== "/" || state.authConfigRevision !== configured.loaded.revision
|| state.issuer !== configured.config.oidc.issuer) {
return oidcCallbackFailed(reply);
}
try {
const identity = await configured.protocol.callback({
currentUrl: callback.currentUrl,
state: callback.state,
nonce: state.nonce,
codeVerifier: state.codeVerifier,
});
if (identity.issuer !== configured.config.oidc.issuer) return oidcCallbackFailed(reply);
const roles = oidcRoles(identity.groups, configured.config);
const absoluteTtlMs = Math.min(
configured.config.session.oidcTtlSeconds * 1000,
identity.tokenExpiresAt.getTime() - Date.now(),
);
if (!Number.isSafeInteger(absoluteTtlMs) || absoluteTtlMs <= 0) return oidcCallbackFailed(reply);
const created = await deps.sessionStore.create({
principal: {
issuer: identity.issuer,
subject: identity.subject,
...(identity.displayName === undefined ? {} : { displayName: identity.displayName }),
roles,
permissions: rolesToPermissions(roles),
isAdmin: roles.includes("admin"),
},
method: "oidc",
remembered: false,
authConfigRevision: configured.loaded.revision,
idleTtlMs: configured.config.session.regularIdleSeconds * 1000,
absoluteTtlMs,
});
reply.setCookie(sessionCookieName(), created.token, cookieOptions(configured.loaded, false));
return reply.redirect(state.returnTo);
} catch {
return oidcCallbackFailed(reply);
}
});
app.post("/auth/logout", async (request, reply) => {
const token = request.authSessionToken;
@@ -278,6 +358,54 @@ function cookieOptions(snapshot: LoadedAuthConfig | undefined, remembered: boole
};
}
function currentOidcConfig(loaded: LoadedAuthConfig | undefined, deps: AuthRouteDependencies):
| { loaded: LoadedAuthConfig; config: OidcAuthenticationConfig; protocol: OidcProtocol }
| undefined {
if (!loaded || loaded.value.mode !== "oidc") return undefined;
const protocol = deps.resolveOidcProtocol?.(loaded) ?? deps.oidcProtocol;
return protocol ? { loaded, config: loaded.value, protocol } : undefined;
}
function randomOidcValue(): string {
return randomBytes(32).toString("base64url");
}
function oidcCallbackUrl(request: FastifyRequest, publicUrl: string): { currentUrl: URL; state: string } | undefined {
if (request.url.length > MAX_OIDC_CALLBACK_QUERY_LENGTH) return undefined;
let supplied: URL;
let target: URL;
try {
supplied = new URL(request.url, "http://callback.invalid");
target = new URL(OIDC_CALLBACK_PATH, publicUrl);
} catch {
return undefined;
}
if (supplied.pathname !== "/auth/oidc/callback") return undefined;
const allowed = new Set(["code", "state", "error", "error_description", "error_uri", "iss"]);
const copied = new URLSearchParams();
let state: string | undefined;
for (const [key, value] of supplied.searchParams) {
if (!allowed.has(key) || value.length > 2048 || copied.has(key)) return undefined;
copied.set(key, value);
if (key === "state") state = value;
}
if (!state || !/^[A-Za-z0-9_-]{43}$/.test(state)) return undefined;
target.search = copied.toString();
return { currentUrl: target, state };
}
function oidcCallbackFailed(reply: FastifyReply) {
return reply.code(401).send({ code: "oidc_callback_failed", error: "OIDC sign-in could not be completed" });
}
function oidcRoles(groups: readonly string[], config: OidcAuthenticationConfig): Role[] {
const roles = new Set<Role>();
for (const group of groups) {
for (const role of config.authorization.groupRoles[group] ?? []) roles.add(role);
}
return [...roles];
}
function loginPayload(request: FastifyRequest): LoginPayload {
const body = request.body;
if (!body || typeof body !== "object" || Array.isArray(body)) return { username: "", password: "", remember: false };
@@ -316,7 +444,3 @@ function loginLimited(reply: FastifyReply): FastifyReply {
function unavailable(reply: FastifyReply): FastifyReply {
return reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" });
}
function notImplemented(reply: FastifyReply): FastifyReply {
return reply.code(501).send({ code: "auth_not_implemented", error: "OIDC login is not implemented" });
}
+8
View File
@@ -68,6 +68,8 @@ export interface OidcStateCreateInput {
nonce: string;
codeVerifier: string;
returnTo: "/";
authConfigRevision: string;
issuer: string;
}
export interface CreatedOidcState {
@@ -188,6 +190,8 @@ const oidcStateRecordSchema = z.strictObject({
nonce: z.string().min(16).max(512).regex(/^[A-Za-z0-9_-]+$/),
codeVerifier: z.string().min(43).max(128).regex(/^[A-Za-z0-9._~-]+$/),
returnTo: z.literal("/"),
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
issuer: z.string().min(1).max(2048).refine((value) => !/\p{Cc}/u.test(value)),
createdAt: timestamp,
expiresAt: timestamp,
}).superRefine((record, context) => {
@@ -226,6 +230,8 @@ const oidcStateInputSchema = z.strictObject({
nonce: z.string().min(16).max(512).regex(/^[A-Za-z0-9_-]+$/),
codeVerifier: z.string().min(43).max(128).regex(/^[A-Za-z0-9._~-]+$/),
returnTo: z.literal("/"),
authConfigRevision: z.string().regex(/^[a-f0-9]{64}$/),
issuer: z.string().min(1).max(2048).refine((value) => !/\p{Cc}/u.test(value)),
});
function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean {
@@ -927,6 +933,8 @@ export function createFileAuthSessionStore(
nonce: validated.nonce,
codeVerifier: validated.codeVerifier,
returnTo: validated.returnTo,
authConfigRevision: validated.authConfigRevision,
issuer: validated.issuer,
createdAt: isoAt(nowMs),
expiresAt: isoAt(expiresMs),
};
+2
View File
@@ -79,6 +79,8 @@ export interface OidcStateRecord {
nonce: string;
codeVerifier: string;
returnTo: "/";
authConfigRevision: string;
issuer: string;
createdAt: string;
expiresAt: string;
}