Skip to content

Commit ba55ef7

Browse files
committed
feat(web): add OAuth JWT validation to API routes
API routes now accept OIDC access tokens via Authorization: Bearer header, matching the gateway's auth flow. Precedence: API key → OAuth JWT → session.
1 parent 945ebdd commit ba55ef7

5 files changed

Lines changed: 127 additions & 5 deletions

File tree

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"@onecli/ui": "workspace:*",
2020
"aws-amplify": "^6.16.2",
2121
"input-otp": "^1.4.2",
22+
"jose": "^6.2.2",
2223
"lucide-react": "^0.562.0",
2324
"next": "16.1.0",
2425
"next-auth": "5.0.0-beta.30",

apps/web/src/lib/api-auth.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { db } from "@onecli/db";
22
import { validateApiKey } from "@/lib/validate-api-key";
3+
import { validateJwt } from "@/lib/validate-jwt";
34
import { getServerSession } from "@/lib/auth/server";
45

56
export interface AuthContext {
@@ -9,7 +10,7 @@ export interface AuthContext {
910

1011
/**
1112
* Resolve the authenticated user + account from an API request.
12-
* Tries API key first (`Authorization: Bearer oc_...`), then falls back to session.
13+
* Tries API key first, then OAuth JWT, then falls back to session.
1314
*/
1415
export const resolveApiAuth = async (
1516
request: Request,
@@ -18,6 +19,10 @@ export const resolveApiAuth = async (
1819
const apiKeyAuth = await validateApiKey(request);
1920
if (apiKeyAuth) return apiKeyAuth;
2021

22+
// OAuth JWT auth — validate OIDC access token
23+
const jwtAuth = await validateJwt(request);
24+
if (jwtAuth) return jwtAuth;
25+
2126
// Session auth — resolve from membership
2227
const session = await getServerSession();
2328
if (!session) return null;

apps/web/src/lib/env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export const OAUTH_ISSUER = process.env.OAUTH_ISSUER ?? "";
5858

5959
export const OAUTH_JWKS_URL = process.env.OAUTH_JWKS_URL ?? "";
6060

61+
export const OAUTH_AUDIENCE = process.env.OAUTH_AUDIENCE ?? "";
62+
6163
export const OAUTH_AUTHORIZATION_URL =
6264
process.env.OAUTH_AUTHORIZATION_URL ?? "";
6365

apps/web/src/lib/validate-jwt.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { createRemoteJWKSet, jwtVerify } from "jose";
2+
import { db } from "@onecli/db";
3+
import { OAUTH_ISSUER, OAUTH_AUDIENCE, OAUTH_JWKS_URL } from "@/lib/env";
4+
import { logger } from "@/lib/logger";
5+
6+
export interface JwtAuth {
7+
userId: string;
8+
accountId: string;
9+
}
10+
11+
const log = logger.child({ module: "validate-jwt" });
12+
13+
// Module-level singletons — persist across requests in the Node.js process.
14+
let cachedJwksUri: string | null = null;
15+
let cachedGetKey: ReturnType<typeof createRemoteJWKSet> | null = null;
16+
17+
const resolveJwksUri = async (): Promise<string | null> => {
18+
if (cachedJwksUri) return cachedJwksUri;
19+
20+
if (OAUTH_JWKS_URL) {
21+
cachedJwksUri = OAUTH_JWKS_URL;
22+
return cachedJwksUri;
23+
}
24+
25+
try {
26+
const res = await fetch(`${OAUTH_ISSUER}/.well-known/openid-configuration`);
27+
const doc = (await res.json()) as { jwks_uri?: string };
28+
if (!doc.jwks_uri) {
29+
log.warn("OIDC discovery response missing jwks_uri");
30+
return null;
31+
}
32+
cachedJwksUri = doc.jwks_uri;
33+
return cachedJwksUri;
34+
} catch (err) {
35+
log.warn({ err }, "OIDC discovery failed");
36+
return null;
37+
}
38+
};
39+
40+
const getKeyFunction = async (): Promise<ReturnType<
41+
typeof createRemoteJWKSet
42+
> | null> => {
43+
if (cachedGetKey) return cachedGetKey;
44+
45+
const jwksUri = await resolveJwksUri();
46+
if (!jwksUri) return null;
47+
48+
cachedGetKey = createRemoteJWKSet(new URL(jwksUri));
49+
return cachedGetKey;
50+
};
51+
52+
const extractBearerToken = (request: Request): string | null => {
53+
const header = request.headers.get("authorization");
54+
if (!header) return null;
55+
const token = header.startsWith("Bearer ") ? header.slice(7).trim() : null;
56+
if (!token || token.startsWith("oc_")) return null;
57+
return token;
58+
};
59+
60+
const lookupUser = async (externalAuthId: string): Promise<JwtAuth | null> => {
61+
const user = await db.user.findUnique({
62+
where: { externalAuthId },
63+
select: {
64+
id: true,
65+
memberships: { select: { accountId: true }, take: 1 },
66+
},
67+
});
68+
69+
if (!user || user.memberships.length === 0) {
70+
log.warn({ sub: externalAuthId }, "JWT auth: user or account not found");
71+
return null;
72+
}
73+
74+
return { userId: user.id, accountId: user.memberships[0]!.accountId };
75+
};
76+
77+
/**
78+
* Validate an OAuth access token (JWT) from a request's `Authorization: Bearer ...` header.
79+
* Verifies signature via JWKS, checks issuer + audience + expiration, then resolves the user.
80+
* Returns null (and logs a warning) on any failure, allowing fallthrough to session auth.
81+
*/
82+
export const validateJwt = async (
83+
request: Request,
84+
): Promise<JwtAuth | null> => {
85+
if (!OAUTH_ISSUER) return null;
86+
87+
const token = extractBearerToken(request);
88+
if (!token) return null;
89+
90+
const getKey = await getKeyFunction();
91+
if (!getKey) return null;
92+
93+
try {
94+
const { payload } = await jwtVerify(token, getKey, {
95+
issuer: OAUTH_ISSUER,
96+
audience: OAUTH_AUDIENCE || undefined,
97+
algorithms: ["RS256", "RS384", "RS512"],
98+
});
99+
100+
const sub = payload.sub;
101+
if (!sub) {
102+
log.warn("JWT missing sub claim");
103+
return null;
104+
}
105+
106+
return await lookupUser(sub);
107+
} catch (err) {
108+
log.warn({ err }, "JWT validation failed");
109+
return null;
110+
}
111+
};

pnpm-lock.yaml

Lines changed: 7 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)