Skip to content

Commit 2dc4c22

Browse files
Replace jose dependency with Web Crypto API
Closes #5. Truly zero-dependency now: - JWT sign/verify via crypto.subtle - JWK thumbprint per RFC 7638 - Supports RS256, ES256, PS256 for token verification - Remove jose from package.json - Bump version to 0.0.7
1 parent 7d1b880 commit 2dc4c22

2 files changed

Lines changed: 143 additions & 27 deletions

File tree

package.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "solid-oidc",
33
"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",
55
"type": "module",
66
"main": "solid-oidc.js",
77
"module": "solid-oidc.js",
@@ -46,7 +46,5 @@
4646
"engines": {
4747
"node": ">=14"
4848
},
49-
"dependencies": {
50-
"jose": "^5.9.6"
51-
}
49+
"dependencies": {}
5250
}

solid-oidc.js

Lines changed: 141 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
/**
22
* solid-oidc.js - Minimal Solid-OIDC client for browsers
33
*
4-
* A zero-build, single-file Solid-OIDC authentication library.
4+
* A zero-build, zero-dependency, single-file Solid-OIDC authentication library.
55
*
6-
* @license MIT
6+
* @license AGPL-3.0-or-later
77
* @author Melvin Carvalho
88
* @see https://github.com/JavaScriptSolidServer/solid-oidc
99
*
@@ -13,20 +13,130 @@
1313
* Implements:
1414
* - RFC 6749 - OAuth 2.0
1515
* - RFC 7636 - PKCE
16+
* - RFC 7638 - JWK Thumbprint
1617
* - RFC 9207 - OAuth 2.0 Authorization Server Issuer Identification
1718
* - RFC 9449 - DPoP (Demonstration of Proof-of-Possession)
1819
* - Solid-OIDC Specification
1920
*/
2021

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+
}
30140

31141
// ============================================================================
32142
// Session Events
@@ -136,15 +246,19 @@ async function generatePKCE() {
136246
// ============================================================================
137247

138248
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+
}
141259
if (ath) payload.ath = ath
142260

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 })
148262
}
149263

150264
async function computeAth(accessToken) {
@@ -212,14 +326,14 @@ async function requestTokens(tokenEndpoint, params, keyPair) {
212326
// ============================================================================
213327

214328
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, {
217330
issuer,
218331
audience: 'solid'
219332
})
220333

221334
// 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)
223337
if (payload.cnf?.jkt !== thumbprint) {
224338
throw new Error('DPoP thumbprint mismatch')
225339
}
@@ -409,8 +523,12 @@ export class Session extends EventTarget {
409523
throw new Error('Missing session data')
410524
}
411525

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+
)
414532

415533
// Exchange code for tokens
416534
const tokens = await requestTokens(tokenEndpoint, {

0 commit comments

Comments
 (0)