Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 45 additions & 41 deletions agentex-ui/auth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { importJWK, SignJWT, type JWK } from 'jose';
import NextAuth, { type NextAuthConfig } from 'next-auth';
import { getToken } from 'next-auth/jwt';

Expand Down Expand Up @@ -114,54 +115,57 @@ export async function oidcEndSessionEndpoint(): Promise<string | undefined> {
return typeof endpoint === 'string' && endpoint ? endpoint : undefined;
}

// ─── private_key_jwt helpers (WebCrypto only — OSS-clean, no deps) ───
function base64url(bytes: Uint8Array): string {
let bin = '';
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// ─── private_key_jwt helpers (JOSE) ───

async function importEs256Key(jwk: JsonWebKey): Promise<CryptoKey> {
return crypto.subtle.importKey(
'jwk',
jwk,
{ name: 'ECDSA', namedCurve: 'P-256' },
false,
['sign']
/**
* Derive the JWS alg for a private JWK: respect an explicit `alg`, else EC by curve
* (ES256/384/512) and RSA → RS256. Throws on key types we don't sign with.
*/
function signingAlgForJwk(jwk: JWK): string {
if (jwk.alg) return jwk.alg;
if (jwk.kty === 'EC') {
const alg = { 'P-256': 'ES256', 'P-384': 'ES384', 'P-521': 'ES512' }[
jwk.crv ?? ''
];
if (!alg)
throw new Error(
`Unsupported EC curve for private_key_jwt: ${jwk.crv ?? 'unknown'}`
);
return alg;
}
if (jwk.kty === 'RSA') return 'RS256';
throw new Error(
`Unsupported key type for private_key_jwt: ${jwk.kty ?? 'unknown'} (expected EC or RSA)`
);
}

/** Sign an RFC 7523 client_assertion for the private_key_jwt refresh call. */
/** Import a private JWK as a signing key. Supports EC (ES256/384/512) and RSA (RS256/384/512/PS*). */
async function importClientPrivateKey(jwk: JWK): Promise<CryptoKey> {
return (await importJWK(jwk, signingAlgForJwk(jwk))) as CryptoKey;
}

/** Sign an RFC 7523 client_assertion for the private_key_jwt refresh call (alg derived from key). */
async function signClientAssertion(
clientId: string,
tokenEndpoint: string,
jwkJson: string
): Promise<string> {
const jwk = JSON.parse(jwkJson) as JsonWebKey & { kid?: string };
const key = await importEs256Key(jwk);
const now = Math.floor(Date.now() / 1000);
const header = {
alg: 'ES256',
typ: 'JWT',
...(jwk.kid ? { kid: jwk.kid } : {}),
};
const payload = {
iss: clientId,
sub: clientId,
aud: tokenEndpoint,
jti: crypto.randomUUID(),
iat: now,
exp: now + 60,
};
const enc = (o: unknown) =>
base64url(new TextEncoder().encode(JSON.stringify(o)));
const input = `${enc(header)}.${enc(payload)}`;
const sig = await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
key,
new TextEncoder().encode(input)
);
return `${input}.${base64url(new Uint8Array(sig))}`;
const jwk = JSON.parse(jwkJson) as JWK;
const alg = signingAlgForJwk(jwk);
const key = await importJWK(jwk, alg);
return new SignJWT({})
.setProtectedHeader({
alg,
typ: 'JWT',
...(jwk.kid ? { kid: jwk.kid } : {}),
})
.setIssuer(clientId)
.setSubject(clientId)
.setAudience(tokenEndpoint)
.setJti(crypto.randomUUID())
.setIssuedAt()
.setExpirationTime('60s')
.sign(key);
}

function buildProvider(id: string) {
Expand All @@ -176,15 +180,15 @@ function buildProvider(id: string) {
checks: ['pkce', 'state'] as ('pkce' | 'state')[],
};
if (privateKeyJwk && privateKeyJwk.trim().length > 0) {
const jwk = JSON.parse(privateKeyJwk) as JsonWebKey & { kid?: string };
const jwk = JSON.parse(privateKeyJwk) as JWK;
return {
...base,
client: { token_endpoint_auth_method: 'private_key_jwt' },
// { key, kid } so oauth4webapi adds `kid` to the login assertion (parity with the
// refresh signer). `key` is a Promise<CryptoKey> resolved before @auth/core reads it.
token: {
clientPrivateKey: {
key: importEs256Key(jwk) as unknown as CryptoKey,
key: importClientPrivateKey(jwk) as unknown as CryptoKey,
...(jwk.kid ? { kid: jwk.kid } : {}),
},
},
Expand Down
1 change: 1 addition & 0 deletions agentex-ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions agentex-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.23.24",
"jose": "^6.0.0",
"lucide-react": "^0.525.0",
"next": "15.5.18",
"next-auth": "^5.0.0-beta.29",
Expand Down
Loading