Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 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 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 20x 20x 18x 18x 20x 20x 20x 2x 2x 2x 2x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 20x 20x 20x 20x 20x 20x 20x 1x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 14x 20x 14x 14x 14x 14x 14x 14x 1x 24x 24x 24x 21x 24x 24x 1x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 12x 1x 24x 24x 24x 1x 1x 26x 25x 25x 25x 25x 25x 1x 1x 24x 24x 24x 24x 25x 2x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 22x 25x 3x 3x 3x 3x 3x 3x 3x 19x 19x 19x 19x 24x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 13x 24x 2x 2x 2x 2x 2x 2x 2x 11x 11x 11x 11x 11x 24x 11x 24x 1x 1x 1x 1x 1x 1x 1x 10x 10x 10x 10x 10x 10x 25x 26x | import { HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions";
import { OAuth2Client } from "google-auth-library";
import type { AuthenticatedUser } from "../models/user";
import { corsHeaders, requestOrigin } from "./http";
import { getOrCreate, normalizeEmail } from "./usersStore";
/**
* withAuth(handler) — the single auth/authorization boundary for every HTTP
* function (contracts/auth.md). Verifies the Google ID token (signature via
* JWKS, iss, aud, exp — real crypto, offline after the certs cache warms),
* requires email_verified, then point-reads the Users table (uncached; tier
* flips and blocks are effective on the next request) — auto-creating the
* row on first sign-in (self-serve signup, plan.md R1). No allowlist: any
* verified-email Google account may sign up; the admin CLI's `block` is the
* only override. Failures return 401/403 BEFORE the wrapped handler — for
* analyze-job, before any OpenAI spend.
*
* `REQUIRE_AUTH` (default false until the gated extension version ships —
* plan.md Rollout) bypasses *enforcement* only: a valid token still yields
* the real identity; absence or failure falls back to a local-dev identity.
*/
export type AuthedHandler = (
request: HttpRequest,
context: InvocationContext,
user: AuthenticatedUser
) => Promise<HttpResponseInit>;
const GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
const DEFAULT_CERTS_URL = "https://www.googleapis.com/oauth2/v1/certs";
const CERTS_TTL_MS = 60 * 60 * 1000;
const LOCAL_DEV_USER: AuthenticatedUser = {
sub: "local-dev",
email: "local-dev@localhost",
tier: "free",
};
const oauthClient = new OAuth2Client();
let certsCache: { certs: Record<string, string>; fetchedAt: number } | null = null;
async function getGoogleCerts(): Promise<Record<string, string>> {
if (certsCache && Date.now() - certsCache.fetchedAt < CERTS_TTL_MS) {
return certsCache.certs;
}
const url = process.env.GOOGLE_OAUTH_CERTS_URL || DEFAULT_CERTS_URL;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch Google certs: HTTP ${response.status}`);
}
const certs = (await response.json()) as Record<string, string>;
certsCache = { certs, fetchedAt: Date.now() };
return certs;
}
interface VerifiedToken {
sub: string;
email: string;
emailVerified: boolean;
}
/**
* GOOGLE_OAUTH_CLIENT_IDS (comma-separated) accepts a token minted for
* either the extension's client ID or the web app's client ID; falls back
* to the single GOOGLE_OAUTH_CLIENT_ID (contracts/web-auth.md, research.md
* R3). Signature / iss / exp / email_verified checks are unchanged.
*/
function configuredClientIds(): string[] {
const raw = process.env.GOOGLE_OAUTH_CLIENT_IDS ?? process.env.GOOGLE_OAUTH_CLIENT_ID;
return (raw ?? "")
.split(",")
.map((id) => id.trim())
.filter(Boolean);
}
async function verifyIdToken(idToken: string): Promise<VerifiedToken> {
const clientIds = configuredClientIds();
if (clientIds.length === 0) {
throw new Error("GOOGLE_OAUTH_CLIENT_ID is not configured");
}
// verifySignedJwtWithCertsAsync = the internals of verifyIdToken with the
// certs supplied by us, so GOOGLE_OAUTH_CERTS_URL can point verification at
// a locally served stub in tests (research.md R9) while the signature,
// aud, iss, and exp checks all still run.
const ticket = await oauthClient.verifySignedJwtWithCertsAsync(
idToken,
await getGoogleCerts(),
clientIds,
GOOGLE_ISSUERS
);
const payload = ticket.getPayload();
if (!payload?.sub || !payload.email) {
throw new Error("Token payload is missing sub or email");
}
return {
sub: payload.sub,
email: payload.email,
emailVerified: payload.email_verified === true,
};
}
function extractBearerToken(request: HttpRequest): string | null {
const header = request.headers.get("authorization");
if (!header) return null;
const match = /^Bearer\s+(.+)$/i.exec(header);
return match ? match[1] : null;
}
function authErrorResponse(
status: 401 | 403,
code: "UNAUTHENTICATED" | "NOT_AUTHORIZED",
message: string,
origin: string | null
): HttpResponseInit {
return {
status,
headers: corsHeaders(origin),
jsonBody: { error: { code, message } },
};
}
function authRequired(): boolean {
return process.env.REQUIRE_AUTH === "true";
}
export function withAuth(handler: AuthedHandler) {
return async (
request: HttpRequest,
context: InvocationContext
): Promise<HttpResponseInit> => {
// Preflight carries no credentials; the handler answers it with 204.
if (request.method === "OPTIONS") {
return handler(request, context, LOCAL_DEV_USER);
}
const token = extractBearerToken(request);
const origin = requestOrigin(request);
if (!authRequired()) {
if (token) {
try {
const verified = await verifyIdToken(token);
return handler(request, context, {
sub: verified.sub,
email: normalizeEmail(verified.email),
tier: "free",
});
} catch {
// Bypass mode never blocks — fall through to the dev identity.
}
}
return handler(request, context, LOCAL_DEV_USER);
}
if (!token) {
return authErrorResponse(
401,
"UNAUTHENTICATED",
"Sign-in required. Send a Google ID token as a Bearer token.",
origin
);
}
let verified: VerifiedToken;
try {
verified = await verifyIdToken(token);
} catch (err) {
context.warn(
"Token verification failed:",
err instanceof Error ? err.message : err
);
return authErrorResponse(
401,
"UNAUTHENTICATED",
"Your session is invalid or has expired. Sign in again.",
origin
);
}
if (!verified.emailVerified) {
return authErrorResponse(
403,
"NOT_AUTHORIZED",
"Sign-in requires a verified Google email address. Verify your email in your Google Account settings (myaccount.google.com), then try again.",
origin
);
}
const email = normalizeEmail(verified.email);
let user: Awaited<ReturnType<typeof getOrCreate>>;
try {
user = await getOrCreate(email, verified.sub);
} catch (err) {
context.error("Users lookup failed:", err);
return {
status: 500,
headers: corsHeaders(origin),
jsonBody: {
error: {
code: "SERVICE_ERROR",
message: "Authorization check failed. Please try again.",
},
},
};
}
if (user.blocked) {
return authErrorResponse(
403,
"NOT_AUTHORIZED",
"Your access has been suspended. Contact the developer to request access.",
origin
);
}
return handler(request, context, {
sub: verified.sub,
email,
tier: user.tier,
});
};
}
|