The Atlas MikeOSS.Azure's docs, bound to the code — and to the migration that built it
108 documents

The migration, PRD by PRD

The trail of changes since the upstream fork: the milestone azure-migration issues in order, each paired with the code it produced. How Mike went from hosted-Supabase SaaS to a single-tenant Azure deployment.

backend/src/lib/auth/providers/entra.ts248 lines · validateEntraToken L97–247
Outline 13 symbols
1import { createPublicKey, createVerify } from "node:crypto";
2import type { JsonWebKey } from "node:crypto";
3import type { AuthValidationResult } from "../types.js";
4import { getConfig } from "../../config.js";
5
6interface JwtHeader {
7 alg?: unknown;
8 kid?: unknown;
9}
10
11interface EntraClaims {
12 oid?: unknown;
13 // Email-shaped claims. v2.0 tokens use preferred_username; v1.0 tokens
14 // typically use upn or unique_name instead. email is an optional claim
15 // in both versions and only present if added in the app registration.
16 preferred_username?: unknown;
17 email?: unknown;
18 upn?: unknown;
19 unique_name?: unknown;
20 // Display-name claims. `name` is a default claim in both v1.0 and v2.0
21 // tokens for human users. given_name / family_name require the optional
22 // claims block in the app registration so they may be undefined.
23 name?: unknown;
24 given_name?: unknown;
25 family_name?: unknown;
26 tid?: unknown;
27 iss?: unknown;
28 aud?: unknown;
29 exp?: unknown;
30 nbf?: unknown;
31 ver?: unknown;
32 groups?: unknown;
33 _claim_names?: unknown;
34}
35
36interface JwkKey {
37 kid?: string;
38 kty?: string;
39 use?: string;
40 n?: string;
41 e?: string;
42}
43
44function b64urlToBuffer(input: string): Buffer {
45 const padded = input + "=".repeat((4 - (input.length % 4)) % 4);
46 return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64");
47}
48
49function asString(v: unknown): string | undefined {
50 return typeof v === "string" ? v : undefined;
51}
52
53function asStringArray(v: unknown): string[] {
54 return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
55}
56
57function nowEpochSeconds() {
58 return Math.floor(Date.now() / 1000);
59}
60
61const JWKS_TTL_MS = 5 * 60 * 1000;
62let cache: { tenantId: string; fetchedAt: number; keys: JwkKey[] } | undefined;
63
64async function getJwks(tenantId: string): Promise<JwkKey[]> {
65 const now = Date.now();
66 if (cache && cache.tenantId === tenantId && now - cache.fetchedAt < JWKS_TTL_MS) {
67 return cache.keys;
68 }
69
70 const response = await fetch(`https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`);
71 if (!response.ok) {
72 throw new Error("Failed to fetch JWKS");
73 }
74
75 const body = (await response.json()) as { keys?: unknown };
76 const keys = Array.isArray(body.keys) ? (body.keys as JwkKey[]) : [];
77 cache = { tenantId, fetchedAt: now, keys };
78 return keys;
79}
80
81function verifySignature(token: string, key: JsonWebKey): boolean {
82 const [headerB64, payloadB64, sigB64] = token.split(".");
83 const verifier = createVerify("RSA-SHA256");
84 verifier.update(`${headerB64}.${payloadB64}`);
85 verifier.end();
86
87 const publicKey = createPublicKey({ key, format: "jwk" });
88 return verifier.verify(publicKey, b64urlToBuffer(sigB64));
89}
90
91// One-shot diagnostic flag so the "Server auth is not configured" path
92// emits a clear log line exactly once per process lifetime. Without this
93// the operator sees opaque 401s with no signal pointing at the missing
94// KV / env state — see 040 Entry 11.
95let configMissingLogged = false;
96
97export async function validateEntraToken(token: string): Promise<AuthValidationResult> {
98 // getConfig() checks process.env first (uppercased, hyphens → underscores)
99 // and falls back to KV via the install backend's UAMI. Marketplace installs
100 // populate KV via create-entra-apps.ps1 or 039's deploy-time provisioning;
101 // OSS / dev deploys still work via ENTRA_TENANT_ID / ENTRA_BACKEND_CLIENT_ID
102 // env vars. Single call site, both sources, no further plumbing required.
103 // Closes 040 Entry 11.
104 const tenantId = await getConfig("entra-tenant-id").catch(() => "");
105 const backendClientId = await getConfig("entra-backend-client-id").catch(() => "");
106
107 if (!tenantId || !backendClientId) {
108 if (!configMissingLogged) {
109 console.error(
110 "auth.entra.config_missing",
111 "Token validation cannot proceed — neither KV (entra-tenant-id / entra-backend-client-id) nor env (ENTRA_TENANT_ID / ENTRA_BACKEND_CLIENT_ID) provided values. Run create-entra-apps.ps1 from /install OR set the env vars on the Container App. This warning is logged once per process.",
112 );
113 configMissingLogged = true;
114 }
115 return { ok: false, status: 401, detail: "Server auth is not configured" };
116 }
117
118 const parts = token.split(".");
119 if (parts.length !== 3) {
120 return { ok: false, status: 401, detail: "Malformed JWT" };
121 }
122
123 let header: JwtHeader;
124 let claims: EntraClaims;
125 try {
126 header = JSON.parse(b64urlToBuffer(parts[0]).toString("utf8"));
127 claims = JSON.parse(b64urlToBuffer(parts[1]).toString("utf8"));
128 } catch {
129 return { ok: false, status: 401, detail: "Malformed JWT" };
130 }
131
132 if (header.alg !== "RS256") {
133 return { ok: false, status: 401, detail: "Invalid token algorithm" };
134 }
135
136 const kid = asString(header.kid);
137 if (!kid) {
138 return { ok: false, status: 401, detail: "Missing token key id" };
139 }
140
141 try {
142 const keys = await getJwks(tenantId);
143 const jwk = keys.find((key) => key.kid === kid && key.kty === "RSA") as JsonWebKey | undefined;
144 if (!jwk || !jwk.n || !jwk.e) {
145 return { ok: false, status: 401, detail: "Invalid or expired token" };
146 }
147
148 if (!verifySignature(token, jwk)) {
149 return { ok: false, status: 401, detail: "Invalid or expired token" };
150 }
151 } catch {
152 return { ok: false, status: 401, detail: "Invalid or expired token" };
153 }
154
155 // Issuer check accepts both token versions. Entra issues v1.0 tokens by
156 // default unless the API app registration sets accessTokenAcceptedVersion: 2
157 // in its manifest — forcing every customer to flip that switch is
158 // unreasonable, so we accept either.
159 // v1.0: https://sts.windows.net/<tid>/
160 // v2.0: https://login.microsoftonline.com/<tid>/v2.0
161 const v1Iss = `https://sts.windows.net/${tenantId}/`;
162 const v2Iss = `https://login.microsoftonline.com/${tenantId}/v2.0`;
163 if (claims.iss !== v1Iss && claims.iss !== v2Iss) {
164 return { ok: false, status: 401, detail: "Invalid issuer" };
165 }
166
167 // Audience check accepts both shapes. v2.0 tokens use the bare client ID
168 // GUID; v1.0 tokens use `api://<guid>` (the application ID URI). Both
169 // identify the same application — the customer's manifest decides which
170 // form lands in the token.
171 const validAudiences = new Set<string>([
172 backendClientId,
173 `api://${backendClientId}`,
174 ]);
175 if (typeof claims.aud !== "string" || !validAudiences.has(claims.aud)) {
176 return { ok: false, status: 401, detail: "Invalid audience" };
177 }
178
179 if (claims.tid !== tenantId) {
180 return { ok: false, status: 401, detail: "Invalid tenant" };
181 }
182
183 const exp = typeof claims.exp === "number" ? claims.exp : undefined;
184 if (!exp) {
185 return { ok: false, status: 401, detail: "Token missing exp claim" };
186 }
187 if (exp <= nowEpochSeconds()) {
188 return { ok: false, status: 401, detail: "Token expired" };
189 }
190
191 const nbf = typeof claims.nbf === "number" ? claims.nbf : undefined;
192 if (nbf && nbf > nowEpochSeconds()) {
193 return { ok: false, status: 401, detail: "Token is not yet valid" };
194 }
195
196 const userId = asString(claims.oid);
197 if (!userId) {
198 return { ok: false, status: 401, detail: "Token missing oid claim" };
199 }
200
201 const overage = typeof claims._claim_names === "object" && claims._claim_names !== null && "groups" in claims._claim_names;
202 if (overage) {
203 console.warn("auth.entra.groups_overage", { provider: "entra", userId });
204 }
205
206 // Email-claim fallback chain covers both v2.0 (preferred_username) and
207 // v1.0 (upn / unique_name) tokens. email is optional in both and only
208 // present if added to the app registration's optional claims.
209 const email =
210 asString(claims.preferred_username) ??
211 asString(claims.email) ??
212 asString(claims.upn) ??
213 asString(claims.unique_name) ??
214 "";
215 if (!email) {
216 console.warn("auth.entra.email_missing", { provider: "entra", userId });
217 }
218
219 // Resolve a display name from the token. `name` is the canonical default
220 // claim; if the directory entry has been kept clean it's just "First Last".
221 // We fall back to assembling given+family ourselves (only present when the
222 // app registration adds them as optional claims), then to the UPN-shaped
223 // preferred_username as a last resort so the row never gets a blank
224 // display name on first login when at least one identifying field exists.
225 const nameClaim = asString(claims.name)?.trim();
226 const givenName = asString(claims.given_name)?.trim();
227 const familyName = asString(claims.family_name)?.trim();
228 const assembledName = [givenName, familyName].filter(Boolean).join(" ");
229 const displayName =
230 nameClaim ||
231 (assembledName.length > 0 ? assembledName : undefined) ||
232 asString(claims.preferred_username)?.trim() ||
233 undefined;
234
235 return {
236 ok: true,
237 principal: {
238 userId,
239 email: email.toLowerCase(),
240 displayName,
241 tenantId,
242 groups: overage ? [] : asStringArray(claims.groups),
243 roles: [],
244 provider: "entra",
245 },
246 };
247}
248