File size: 4,736 Bytes
c6302b1
 
 
 
 
 
 
bf25558
 
 
 
 
 
c6302b1
 
 
bf25558
 
c6302b1
 
 
 
 
 
 
 
 
 
 
bf25558
c6302b1
 
bf25558
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c6302b1
 
 
bf25558
 
 
 
 
 
 
 
 
 
 
 
 
c6302b1
 
 
 
 
 
 
 
 
 
 
 
 
bf25558
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c6302b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf25558
c6302b1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
import { createHmac, timingSafeEqual } from "node:crypto";

const PREFIX = "xp1";

export type TokenPayload = {
  username: string;
  expiresAt: number;
  orgs?: readonly TokenOrg[];
};

export type TokenOrg = {
  sub: string;
  name?: string;
};

export type TokenVerification =
  | { ok: true; username: string; orgs: readonly TokenOrg[] }
  | { ok: false; reason: "malformed" | "bad-signature" | "expired" };

function b64url(data: Buffer | string): string {
  return Buffer.from(data).toString("base64url");
}

function sign(secret: string, payload: string): string {
  return createHmac("sha256", secret).update(payload).digest("base64url");
}

/**
 * Mint a stateless pool token: `xp1.<payload>.<signature>` where the payload
 * is base64url JSON `{username, expiresAt, orgs?}` and the signature is
 * HMAC-SHA256 over `xp1.<payload>` with the pool signing secret.
 */
export function mintPoolToken(
  secret: string,
  subject: string | { username: string; orgs?: readonly TokenOrg[] },
  expiresAt: Date,
): string {
  const identity = typeof subject === "string" ? { username: subject } : subject;
  const payload = b64url(
    JSON.stringify({
      username: identity.username,
      expiresAt: expiresAt.getTime(),
      ...(identity.orgs === undefined || identity.orgs.length === 0
        ? {}
        : { orgs: normalizeTokenOrgs(identity.orgs) }),
    }),
  );
  return `${PREFIX}.${payload}.${sign(secret, `${PREFIX}.${payload}`)}`;
}

function normalizeTokenOrgs(orgs: readonly TokenOrg[]): TokenOrg[] {
  const bySub = new Map<string, TokenOrg>();
  for (const org of orgs) {
    const sub = org.sub.trim();
    if (sub.length === 0) continue;
    const normalized: TokenOrg = { sub };
    const name = org.name?.trim();
    if (name !== undefined && name.length > 0) normalized.name = name;
    bySub.set(sub, normalized);
  }
  return [...bySub.values()];
}

function decodePayload(payload: string): TokenPayload | undefined {
  let parsed: unknown;
  try {
    parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
  } catch {
    return undefined;
  }
  if (typeof parsed !== "object" || parsed === null) return undefined;
  const candidate = parsed as Record<string, unknown>;
  if (typeof candidate["username"] !== "string" || candidate["username"].length === 0) {
    return undefined;
  }
  if (typeof candidate["expiresAt"] !== "number") return undefined;
  const orgs = decodeOrgs(candidate["orgs"]);
  if (orgs === undefined) return undefined;
  return { username: candidate["username"], expiresAt: candidate["expiresAt"], orgs };
}

function decodeOrgs(raw: unknown): readonly TokenOrg[] | undefined {
  if (raw === undefined) return [];
  if (!Array.isArray(raw)) return undefined;
  const orgs: TokenOrg[] = [];
  for (const item of raw) {
    const org = decodeOrg(item);
    if (org === undefined) return undefined;
    orgs.push(org);
  }
  return normalizeTokenOrgs(orgs);
}

function decodeOrg(raw: unknown): TokenOrg | undefined {
  if (typeof raw !== "object" || raw === null) return undefined;
  const candidate = raw as Record<string, unknown>;
  if (typeof candidate["sub"] !== "string" || candidate["sub"].length === 0) return undefined;
  const org: TokenOrg = { sub: candidate["sub"] };
  const name = decodeOptionalName(candidate["name"]);
  if (name === undefined && candidate["name"] !== undefined) return undefined;
  if (name !== undefined) org.name = name;
  return org;
}

function decodeOptionalName(raw: unknown): string | undefined {
  if (raw === undefined) return undefined;
  return typeof raw === "string" && raw.length > 0 ? raw : undefined;
}

function splitToken(token: string): [string, string, string] | undefined {
  const parts = token.split(".");
  if (parts.length !== 3 || parts[0] !== PREFIX || parts[1] === "" || parts[2] === "") {
    return undefined;
  }
  return parts as [string, string, string];
}

/** Verify a pool token's signature and expiry; never throws. */
export function verifyPoolToken(secret: string, token: string, now: Date): TokenVerification {
  const parts = splitToken(token);
  if (parts === undefined) return { ok: false, reason: "malformed" };
  const [prefix, payload, signature] = parts;
  const expected = Buffer.from(sign(secret, `${prefix}.${payload}`));
  const provided = Buffer.from(signature);
  if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) {
    return { ok: false, reason: "bad-signature" };
  }
  const decoded = decodePayload(payload);
  if (decoded === undefined) return { ok: false, reason: "malformed" };
  if (decoded.expiresAt <= now.getTime()) return { ok: false, reason: "expired" };
  return { ok: true, username: decoded.username, orgs: decoded.orgs ?? [] };
}