| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent 7d1b880 commit 2dc4c22
2 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -1,7 +1,7 @@ | |||
| 1 | 1 | { | |
| 2 | 2 | "name": "solid-oidc", | |
| 3 | 3 | "version": "0.0.6", | |
| 4 | - "description": "Minimal, zero-build Solid-OIDC client for browsers", | ||
| 4 | + "description": "Minimal, zero-build, zero-dependency Solid-OIDC client for browsers", | ||
| 5 | 5 | "type": "module", | |
| 6 | 6 | "main": "solid-oidc.js", | |
| 7 | 7 | "module": "solid-oidc.js", | |
@@ -46,7 +46,5 @@ | |||
| 46 | 46 | "engines": { | |
| 47 | 47 | "node": ">=14" | |
| 48 | 48 | }, | |
| 49 | - "dependencies": { | ||
| 50 | - "jose": "^5.9.6" | ||
| 51 | - } | ||
| 49 | + "dependencies": {} | ||
| 52 | 50 | } | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -1,9 +1,9 @@ | |||
| 1 | 1 | /** | |
| 2 | 2 | * solid-oidc.js - Minimal Solid-OIDC client for browsers | |
| 3 | 3 | * | |
| 4 | - * A zero-build, single-file Solid-OIDC authentication library. | ||
| 4 | + * A zero-build, zero-dependency, single-file Solid-OIDC authentication library. | ||
| 5 | 5 | * | |
| 6 | - * @license MIT | ||
| 6 | + * @license AGPL-3.0-or-later | ||
| 7 | 7 | * @author Melvin Carvalho | |
| 8 | 8 | * @see https://github.com/JavaScriptSolidServer/solid-oidc | |
| 9 | 9 | * | |
@@ -13,20 +13,130 @@ | |||
| 13 | 13 | * Implements: | |
| 14 | 14 | * - RFC 6749 - OAuth 2.0 | |
| 15 | 15 | * - RFC 7636 - PKCE | |
| 16 | + * - RFC 7638 - JWK Thumbprint | ||
| 16 | 17 | * - RFC 9207 - OAuth 2.0 Authorization Server Issuer Identification | |
| 17 | 18 | * - RFC 9449 - DPoP (Demonstration of Proof-of-Possession) | |
| 18 | 19 | * - Solid-OIDC Specification | |
| 19 | 20 | */ | |
| 20 | 21 | ||
| 21 | - import { | ||
| 22 | - SignJWT, | ||
| 23 | - generateKeyPair, | ||
| 24 | - decodeJwt, | ||
| 25 | - exportJWK, | ||
| 26 | - createRemoteJWKSet, | ||
| 27 | - jwtVerify, | ||
| 28 | - calculateJwkThumbprint | ||
| 29 | - } from 'jose' | ||
| 22 | + // ============================================================================ | ||
| 23 | + // Base64url Helpers | ||
| 24 | + // ============================================================================ | ||
| 25 | + | ||
| 26 | + function base64urlEncode(data) { | ||
| 27 | + if (data instanceof ArrayBuffer) data = new Uint8Array(data) | ||
| 28 | + return btoa(String.fromCharCode(...data)) | ||
| 29 | + .replace(/\+/g, '-') | ||
| 30 | + .replace(/\//g, '_') | ||
| 31 | + .replace(/=+$/, '') | ||
| 32 | + } | ||
| 33 | + | ||
| 34 | + function base64urlDecode(str) { | ||
| 35 | + str = str.replace(/-/g, '+').replace(/_/g, '/') | ||
| 36 | + while (str.length % 4) str += '=' | ||
| 37 | + return Uint8Array.from(atob(str), c => c.charCodeAt(0)) | ||
| 38 | + } | ||
| 39 | + | ||
| 40 | + // ============================================================================ | ||
| 41 | + // Web Crypto JWT Helpers (replaces jose dependency) | ||
| 42 | + // ============================================================================ | ||
| 43 | + | ||
| 44 | + function decodeJwt(token) { | ||
| 45 | + const parts = token.split('.') | ||
| 46 | + if (parts.length !== 3) throw new Error('Invalid JWT') | ||
| 47 | + return JSON.parse(new TextDecoder().decode(base64urlDecode(parts[1]))) | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + async function signJWT(payload, privateKey, protectedHeader) { | ||
| 51 | + const header = base64urlEncode(new TextEncoder().encode(JSON.stringify(protectedHeader))) | ||
| 52 | + const body = base64urlEncode(new TextEncoder().encode(JSON.stringify(payload))) | ||
| 53 | + const signingInput = new TextEncoder().encode(`${header}.${body}`) | ||
| 54 | + | ||
| 55 | + const signature = await crypto.subtle.sign( | ||
| 56 | + { name: 'ECDSA', hash: 'SHA-256' }, | ||
| 57 | + privateKey, | ||
| 58 | + signingInput | ||
| 59 | + ) | ||
| 60 | + | ||
| 61 | + return `${header}.${body}.${base64urlEncode(signature)}` | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + function getImportAlgorithm(alg) { | ||
| 65 | + const algorithms = { | ||
| 66 | + ES256: { name: 'ECDSA', namedCurve: 'P-256' }, | ||
| 67 | + ES384: { name: 'ECDSA', namedCurve: 'P-384' }, | ||
| 68 | + RS256: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, | ||
| 69 | + RS384: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-384' }, | ||
| 70 | + RS512: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-512' }, | ||
| 71 | + PS256: { name: 'RSA-PSS', hash: 'SHA-256' }, | ||
| 72 | + PS384: { name: 'RSA-PSS', hash: 'SHA-384' }, | ||
| 73 | + PS512: { name: 'RSA-PSS', hash: 'SHA-512' } | ||
| 74 | + } | ||
| 75 | + if (!algorithms[alg]) throw new Error(`Unsupported algorithm: ${alg}`) | ||
| 76 | + return algorithms[alg] | ||
| 77 | + } | ||
| 78 | + | ||
| 79 | + function getVerifyAlgorithm(alg) { | ||
| 80 | + if (alg.startsWith('ES')) return { name: 'ECDSA', hash: `SHA-${alg.slice(2)}` } | ||
| 81 | + if (alg.startsWith('RS')) return { name: 'RSASSA-PKCS1-v1_5' } | ||
| 82 | + if (alg.startsWith('PS')) return { name: 'RSA-PSS', saltLength: parseInt(alg.slice(2)) / 8 } | ||
| 83 | + throw new Error(`Unsupported algorithm: ${alg}`) | ||
| 84 | + } | ||
| 85 | + | ||
| 86 | + async function verifyJWT(token, jwksUri, options = {}) { | ||
| 87 | + const parts = token.split('.') | ||
| 88 | + if (parts.length !== 3) throw new Error('Invalid JWT') | ||
| 89 | + | ||
| 90 | + const header = JSON.parse(new TextDecoder().decode(base64urlDecode(parts[0]))) | ||
| 91 | + const payload = JSON.parse(new TextDecoder().decode(base64urlDecode(parts[1]))) | ||
| 92 | + | ||
| 93 | + if (options.issuer && payload.iss !== options.issuer) { | ||
| 94 | + throw new Error(`Issuer mismatch: ${payload.iss} !== ${options.issuer}`) | ||
| 95 | + } | ||
| 96 | + if (options.audience) { | ||
| 97 | + const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud] | ||
| 98 | + if (!aud.includes(options.audience)) throw new Error('Audience mismatch') | ||
| 99 | + } | ||
| 100 | + | ||
| 101 | + // Fetch JWKS and find matching key | ||
| 102 | + const response = await fetch(jwksUri) | ||
| 103 | + if (!response.ok) throw new Error(`JWKS fetch failed: ${response.status}`) | ||
| 104 | + const jwks = await response.json() | ||
| 105 | + | ||
| 106 | + const jwk = header.kid | ||
| 107 | + ? jwks.keys.find(k => k.kid === header.kid) | ||
| 108 | + : jwks.keys.find(k => k.alg === header.alg || (!k.alg && (!k.use || k.use === 'sig'))) | ||
| 109 | + if (!jwk) throw new Error('No matching key found in JWKS') | ||
| 110 | + | ||
| 111 | + const publicKey = await crypto.subtle.importKey( | ||
| 112 | + 'jwk', jwk, getImportAlgorithm(header.alg), false, ['verify'] | ||
| 113 | + ) | ||
| 114 | + | ||
| 115 | + const valid = await crypto.subtle.verify( | ||
| 116 | + getVerifyAlgorithm(header.alg), | ||
| 117 | + publicKey, | ||
| 118 | + base64urlDecode(parts[2]), | ||
| 119 | + new TextEncoder().encode(`${parts[0]}.${parts[1]}`) | ||
| 120 | + ) | ||
| 121 | + if (!valid) throw new Error('Invalid signature') | ||
| 122 | + | ||
| 123 | + return { payload, protectedHeader: header } | ||
| 124 | + } | ||
| 125 | + | ||
| 126 | + /** JWK Thumbprint per RFC 7638 */ | ||
| 127 | + async function calculateJwkThumbprint(jwk) { | ||
| 128 | + // Required members in lexicographic order per key type | ||
| 129 | + let thumbprintInput | ||
| 130 | + if (jwk.kty === 'EC') { | ||
| 131 | + thumbprintInput = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }) | ||
| 132 | + } else if (jwk.kty === 'RSA') { | ||
| 133 | + thumbprintInput = JSON.stringify({ e: jwk.e, kty: jwk.kty, n: jwk.n }) | ||
| 134 | + } else { | ||
| 135 | + throw new Error(`Unsupported key type: ${jwk.kty}`) | ||
| 136 | + } | ||
| 137 | + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(thumbprintInput)) | ||
| 138 | + return base64urlEncode(digest) | ||
| 139 | + } | ||
| 30 | 140 | ||
| 31 | 141 | // ============================================================================ | |
| 32 | 142 | // Session Events | |
@@ -136,15 +246,19 @@ async function generatePKCE() { | |||
| 136 | 246 | // ============================================================================ | |
| 137 | 247 | ||
| 138 | 248 | async function createDPoPToken(keyPair, htu, htm, ath = null) { | |
| 139 | - const publicJwk = await exportJWK(keyPair.publicKey) | ||
| 140 | - const payload = { htu, htm } | ||
| 249 | + const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) | ||
| 250 | + // Strip non-required fields for cleaner header | ||
| 251 | + const jwk = { kty: publicJwk.kty, crv: publicJwk.crv, x: publicJwk.x, y: publicJwk.y } | ||
| 252 | + | ||
| 253 | + const payload = { | ||
| 254 | + htu, | ||
| 255 | + htm, | ||
| 256 | + iat: Math.floor(Date.now() / 1000), | ||
| 257 | + jti: crypto.randomUUID() | ||
| 258 | + } | ||
| 141 | 259 | if (ath) payload.ath = ath | |
| 142 | 260 | ||
| 143 | - return new SignJWT(payload) | ||
| 144 | - .setIssuedAt() | ||
| 145 | - .setJti(crypto.randomUUID()) | ||
| 146 | - .setProtectedHeader({ alg: 'ES256', typ: 'dpop+jwt', jwk: publicJwk }) | ||
| 147 | - .sign(keyPair.privateKey) | ||
| 261 | + return signJWT(payload, keyPair.privateKey, { alg: 'ES256', typ: 'dpop+jwt', jwk }) | ||
| 148 | 262 | } | |
| 149 | 263 | ||
| 150 | 264 | async function computeAth(accessToken) { | |
@@ -212,14 +326,14 @@ async function requestTokens(tokenEndpoint, params, keyPair) { | |||
| 212 | 326 | // ============================================================================ | |
| 213 | 327 | ||
| 214 | 328 | async function validateAccessToken(accessToken, jwksUri, issuer, clientId, keyPair) { | |
| 215 | - const jwks = createRemoteJWKSet(new URL(jwksUri)) | ||
| 216 | - const { payload } = await jwtVerify(accessToken, jwks, { | ||
| 329 | + const { payload } = await verifyJWT(accessToken, jwksUri, { | ||
| 217 | 330 | issuer, | |
| 218 | 331 | audience: 'solid' | |
| 219 | 332 | }) | |
| 220 | 333 | ||
| 221 | 334 | // Verify DPoP binding | |
| 222 | - const thumbprint = await calculateJwkThumbprint(await exportJWK(keyPair.publicKey)) | ||
| 335 | + const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey) | ||
| 336 | + const thumbprint = await calculateJwkThumbprint(publicJwk) | ||
| 223 | 337 | if (payload.cnf?.jkt !== thumbprint) { | |
| 224 | 338 | throw new Error('DPoP thumbprint mismatch') | |
| 225 | 339 | } | |
@@ -409,8 +523,12 @@ export class Session extends EventTarget { | |||
| 409 | 523 | throw new Error('Missing session data') | |
| 410 | 524 | } | |
| 411 | 525 | ||
| 412 | - // Generate DPoP key pair | ||
| 413 | - const keyPair = await generateKeyPair('ES256') | ||
| 526 | + // Generate DPoP key pair (ES256 / P-256) | ||
| 527 | + const keyPair = await crypto.subtle.generateKey( | ||
| 528 | + { name: 'ECDSA', namedCurve: 'P-256' }, | ||
| 529 | + true, | ||
| 530 | + ['sign', 'verify'] | ||
| 531 | + ) | ||
| 414 | 532 | ||
| 415 | 533 | // Exchange code for tokens | |
| 416 | 534 | const tokens = await requestTokens(tokenEndpoint, { | |
| Back | FazBrowse Home | New Git URL |
0 commit comments