Skip to content

Commit c53edb9

Browse files
committed
feat(web): accept JWT Bearer auth on /api/auth/sync for account provisioning
API-first clients can now provision their account by calling GET /api/auth/sync with a JWT access token instead of requiring a browser login first. Email and name are read from JWT claims.
1 parent 37e95e1 commit c53edb9

2 files changed

Lines changed: 85 additions & 11 deletions

File tree

apps/web/src/app/api/auth/sync/route.ts

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { type NextRequest, NextResponse } from "next/server";
22
import { db } from "@onecli/db";
33
import { getServerSession } from "@/lib/auth/server";
4+
import { verifyAndResolveIdentity } from "@/lib/validate-jwt";
45
import { logger } from "@/lib/logger";
56
import { DEFAULT_AGENT_NAME } from "@/lib/constants";
67
import { seedDemoSecret } from "@/lib/services/secret-service";
@@ -12,37 +13,57 @@ import { getSessionAttributes, onUserCreated } from "@/lib/auth/session-hooks";
1213
* GET /api/auth/sync
1314
*
1415
* Single endpoint that handles the full auth → DB sync flow:
15-
* 1. Reads the auth session (cookie/token)
16+
* 1. Reads the auth session (cookie/token) or validates a JWT Bearer token
1617
* 2. Upserts the user in the database
1718
* 3. Ensures the user has an Account + AccountMember + ApiKey
1819
* 4. Seeds defaults (agent, demo secret) into the account
1920
* 5. Returns the user profile
2021
*
21-
* Called by the login page after auth and by the dashboard layout on mount.
22-
* Returns 401 if no valid session exists.
22+
* Called by the login page after auth, by the dashboard layout on mount,
23+
* or by API clients with a JWT access token to provision their account.
24+
* Returns 401 if no valid session or token exists.
2325
*/
2426
export const GET = async (request: NextRequest) => {
2527
try {
28+
// Try session auth first, then JWT Bearer token
2629
const session = await getServerSession();
27-
if (!session || !session.email) {
28-
return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
30+
31+
let authId: string;
32+
let email: string;
33+
let name: string | null | undefined;
34+
35+
if (session?.email) {
36+
authId = session.id;
37+
email = session.email;
38+
name = session.name;
39+
} else {
40+
const identity = await verifyAndResolveIdentity(request);
41+
if (!identity) {
42+
return NextResponse.json(
43+
{ error: "Not authenticated" },
44+
{ status: 401 },
45+
);
46+
}
47+
authId = identity.sub;
48+
email = identity.email;
49+
name = identity.name;
2950
}
3051

3152
const extra = getSessionAttributes(request);
3253

3354
// Upsert user by email — creates on first login, updates on subsequent.
3455
const user = await db.user.upsert({
35-
where: { email: session.email },
56+
where: { email },
3657
create: {
37-
externalAuthId: session.id,
38-
email: session.email,
39-
name: session.name,
58+
externalAuthId: authId,
59+
email,
60+
name: name ?? null,
4061
lastLoginAt: new Date(),
4162
...extra,
4263
},
4364
update: {
44-
externalAuthId: session.id,
45-
name: session.name,
65+
externalAuthId: authId,
66+
name: name ?? null,
4667
lastLoginAt: new Date(),
4768
...extra,
4869
},

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,56 @@ export const validateJwt = async (
109109
return null;
110110
}
111111
};
112+
113+
// ── Identity resolution (JWT verify + email/name from claims) ──────────
114+
115+
export interface ResolvedIdentity {
116+
sub: string;
117+
email: string;
118+
name?: string;
119+
}
120+
121+
/**
122+
* Verify a JWT access token and resolve the user's identity (sub + email + name)
123+
* from the token claims, without performing a database lookup.
124+
*
125+
* Returns null if the JWT is invalid or the email claim is missing.
126+
*/
127+
export const verifyAndResolveIdentity = async (
128+
request: Request,
129+
): Promise<ResolvedIdentity | null> => {
130+
if (!OAUTH_ISSUER) return null;
131+
132+
const token = extractBearerToken(request);
133+
if (!token) return null;
134+
135+
const getKey = await getKeyFunction();
136+
if (!getKey) return null;
137+
138+
try {
139+
const { payload } = await jwtVerify(token, getKey, {
140+
issuer: OAUTH_ISSUER,
141+
audience: OAUTH_AUDIENCE || undefined,
142+
algorithms: ["RS256", "RS384", "RS512"],
143+
});
144+
145+
const sub = payload.sub;
146+
if (!sub) {
147+
log.warn("JWT missing sub claim");
148+
return null;
149+
}
150+
151+
const email = typeof payload.email === "string" ? payload.email : undefined;
152+
if (!email) {
153+
log.warn({ sub }, "JWT missing email claim");
154+
return null;
155+
}
156+
157+
const name = typeof payload.name === "string" ? payload.name : undefined;
158+
159+
return { sub, email, name };
160+
} catch (err) {
161+
log.warn({ err }, "JWT verification failed");
162+
return null;
163+
}
164+
};

0 commit comments

Comments
 (0)