Skip to content

Commit 69bce65

Browse files
Add programmatic credentials endpoint for CTH compatibility
- POST /idp/credentials accepts email/password - Returns Bearer token (simple) for dev/testing - Returns DPoP-bound JWT when DPoP proof header provided - Supports JSON and form-urlencoded bodies - 8 new tests for credentials endpoint This enables running the Solid conformance test harness against JSS. 182 tests passing
1 parent 46dbfcd commit 69bce65

4 files changed

Lines changed: 436 additions & 3 deletions

File tree

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,28 @@ Response:
306306

307307
OIDC Discovery: `/.well-known/openid-configuration`
308308

309+
### Programmatic Login (CTH Compatible)
310+
311+
For automated testing and scripts, use the credentials endpoint:
312+
313+
```bash
314+
curl -X POST http://localhost:3000/idp/credentials \
315+
-H "Content-Type: application/json" \
316+
-d '{"email": "alice@example.com", "password": "secret123"}'
317+
```
318+
319+
Response:
320+
```json
321+
{
322+
"access_token": "...",
323+
"token_type": "Bearer",
324+
"expires_in": 3600,
325+
"webid": "http://localhost:3000/alice/#me"
326+
}
327+
```
328+
329+
For DPoP-bound tokens (Solid-OIDC compliant), include a DPoP proof header.
330+
309331
### Solid-OIDC (External IdP)
310332

311333
The server also accepts DPoP-bound access tokens from external Solid identity providers:
@@ -355,7 +377,7 @@ Server: pub http://localhost:3000/alice/public/data.json (on change)
355377
npm test
356378
```
357379

358-
Currently passing: **174 tests** (including 27 conformance tests)
380+
Currently passing: **182 tests** (including 27 conformance tests)
359381

360382
## Project Structure
361383

src/idp/credentials.js

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
/**
2+
* Programmatic credentials endpoint for CTH compatibility
3+
* Allows obtaining tokens via email/password without browser interaction
4+
*/
5+
6+
import * as jose from 'jose';
7+
import crypto from 'crypto';
8+
import { authenticate, findByEmail } from './accounts.js';
9+
import { getJwks } from './keys.js';
10+
import { createToken as createSimpleToken } from '../auth/token.js';
11+
12+
/**
13+
* Handle POST /idp/credentials
14+
* Accepts email/password and returns access token
15+
*
16+
* Request body (JSON or form):
17+
* - email: User email
18+
* - password: User password
19+
*
20+
* Optional headers:
21+
* - DPoP: DPoP proof JWT (for DPoP-bound tokens)
22+
*
23+
* Response:
24+
* - access_token: JWT access token with webid claim
25+
* - token_type: 'DPoP' or 'Bearer'
26+
* - expires_in: Token lifetime in seconds
27+
* - webid: User's WebID
28+
*/
29+
export async function handleCredentials(request, reply, issuer) {
30+
// Parse body (JSON or form-encoded)
31+
let email, password;
32+
33+
const contentType = request.headers['content-type'] || '';
34+
let body = request.body;
35+
36+
// Convert buffer to string if needed
37+
if (Buffer.isBuffer(body)) {
38+
body = body.toString('utf-8');
39+
}
40+
41+
if (contentType.includes('application/json')) {
42+
// JSON - Fastify parses this automatically
43+
if (typeof body === 'string') {
44+
try {
45+
body = JSON.parse(body);
46+
} catch {
47+
// Not valid JSON
48+
}
49+
}
50+
email = body?.email;
51+
password = body?.password;
52+
} else if (contentType.includes('application/x-www-form-urlencoded')) {
53+
// Parse form-encoded body
54+
if (typeof body === 'string') {
55+
const params = new URLSearchParams(body);
56+
email = params.get('email');
57+
password = params.get('password');
58+
} else if (typeof body === 'object') {
59+
email = body?.email;
60+
password = body?.password;
61+
}
62+
} else {
63+
// Try to parse as object
64+
if (typeof body === 'object') {
65+
email = body?.email;
66+
password = body?.password;
67+
}
68+
}
69+
70+
// Validate input
71+
if (!email || !password) {
72+
return reply.code(400).send({
73+
error: 'invalid_request',
74+
error_description: 'Email and password are required',
75+
});
76+
}
77+
78+
// Authenticate
79+
const account = await authenticate(email, password);
80+
81+
if (!account) {
82+
return reply.code(401).send({
83+
error: 'invalid_grant',
84+
error_description: 'Invalid email or password',
85+
});
86+
}
87+
88+
// Check for DPoP header
89+
const dpopHeader = request.headers['dpop'];
90+
let dpopJkt = null;
91+
92+
if (dpopHeader) {
93+
try {
94+
// Validate DPoP proof and extract thumbprint
95+
dpopJkt = await validateDpopProof(dpopHeader, 'POST', `${issuer}/idp/credentials`);
96+
} catch (err) {
97+
return reply.code(400).send({
98+
error: 'invalid_dpop_proof',
99+
error_description: err.message,
100+
});
101+
}
102+
}
103+
104+
const expiresIn = 3600; // 1 hour
105+
let accessToken;
106+
let tokenType;
107+
108+
if (dpopJkt) {
109+
// Generate DPoP-bound JWT for Solid-OIDC clients
110+
const jwks = await getJwks();
111+
const signingKey = jwks.keys[0];
112+
const privateKey = await jose.importJWK(signingKey, 'ES256');
113+
114+
const now = Math.floor(Date.now() / 1000);
115+
const tokenPayload = {
116+
iss: issuer,
117+
sub: account.id,
118+
aud: 'solid',
119+
webid: account.webId,
120+
iat: now,
121+
exp: now + expiresIn,
122+
jti: crypto.randomUUID(),
123+
client_id: 'credentials_client',
124+
scope: 'openid webid',
125+
cnf: { jkt: dpopJkt },
126+
};
127+
128+
accessToken = await new jose.SignJWT(tokenPayload)
129+
.setProtectedHeader({ alg: 'ES256', kid: signingKey.kid })
130+
.sign(privateKey);
131+
tokenType = 'DPoP';
132+
} else {
133+
// Generate simple token for Bearer auth (development/testing)
134+
accessToken = createSimpleToken(account.webId, expiresIn);
135+
tokenType = 'Bearer';
136+
}
137+
138+
// Response
139+
const response = {
140+
access_token: accessToken,
141+
token_type: tokenType,
142+
expires_in: expiresIn,
143+
webid: account.webId,
144+
id: account.id,
145+
};
146+
147+
reply.header('Cache-Control', 'no-store');
148+
reply.header('Pragma', 'no-cache');
149+
150+
return response;
151+
}
152+
153+
/**
154+
* Validate a DPoP proof and return the JWK thumbprint
155+
* @param {string} proof - The DPoP proof JWT
156+
* @param {string} method - HTTP method
157+
* @param {string} url - Request URL
158+
* @returns {Promise<string>} - JWK thumbprint
159+
*/
160+
async function validateDpopProof(proof, method, url) {
161+
// Decode the proof header to get the public key
162+
const protectedHeader = jose.decodeProtectedHeader(proof);
163+
164+
// DPoP proofs must have a JWK in the header
165+
if (!protectedHeader.jwk) {
166+
throw new Error('DPoP proof must contain jwk in header');
167+
}
168+
169+
// Verify the proof signature
170+
const publicKey = await jose.importJWK(protectedHeader.jwk, protectedHeader.alg);
171+
172+
let payload;
173+
try {
174+
const result = await jose.jwtVerify(proof, publicKey, {
175+
typ: 'dpop+jwt',
176+
maxTokenAge: '60s',
177+
});
178+
payload = result.payload;
179+
} catch (err) {
180+
throw new Error(`DPoP proof verification failed: ${err.message}`);
181+
}
182+
183+
// Verify htm (HTTP method)
184+
if (payload.htm !== method) {
185+
throw new Error(`DPoP htm mismatch: expected ${method}, got ${payload.htm}`);
186+
}
187+
188+
// Verify htu (HTTP URL) - compare without query string
189+
const proofUrl = new URL(payload.htu);
190+
const requestUrl = new URL(url);
191+
if (proofUrl.origin + proofUrl.pathname !== requestUrl.origin + requestUrl.pathname) {
192+
throw new Error('DPoP htu mismatch');
193+
}
194+
195+
// Calculate JWK thumbprint
196+
const thumbprint = await jose.calculateJwkThumbprint(protectedHeader.jwk, 'sha256');
197+
198+
return thumbprint;
199+
}
200+
201+
/**
202+
* Handle GET /idp/credentials
203+
* Returns info about the credentials endpoint
204+
*/
205+
export function handleCredentialsInfo(request, reply, issuer) {
206+
return {
207+
endpoint: `${issuer}/idp/credentials`,
208+
method: 'POST',
209+
description: 'Obtain access tokens using email and password',
210+
content_types: ['application/json', 'application/x-www-form-urlencoded'],
211+
parameters: {
212+
email: 'User email address',
213+
password: 'User password',
214+
},
215+
optional_headers: {
216+
DPoP: 'DPoP proof JWT for DPoP-bound tokens',
217+
},
218+
response: {
219+
access_token: 'JWT access token with webid claim',
220+
token_type: 'DPoP or Bearer',
221+
expires_in: 'Token lifetime in seconds',
222+
webid: 'User WebID',
223+
},
224+
};
225+
}

src/idp/index.js

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import {
1212
handleConsent,
1313
handleAbort,
1414
} from './interactions.js';
15+
import {
16+
handleCredentials,
17+
handleCredentialsInfo,
18+
} from './credentials.js';
1519

1620
/**
1721
* IdP Fastify Plugin
@@ -43,8 +47,8 @@ export async function idpPlugin(fastify, options) {
4347
// Mount oidc-provider on /idp path
4448
// oidc-provider is a Koa app, middie handles the bridge
4549
fastify.use('/idp', (req, res, next) => {
46-
// Skip our custom interaction routes
47-
if (req.url.startsWith('/interaction/')) {
50+
// Skip our custom routes (handled by Fastify)
51+
if (req.url.startsWith('/interaction/') || req.url.startsWith('/credentials')) {
4852
return next();
4953
}
5054
// Let oidc-provider handle everything else
@@ -89,6 +93,19 @@ export async function idpPlugin(fastify, options) {
8993
return jwks;
9094
});
9195

96+
// Programmatic credentials endpoint for CTH compatibility
97+
// Allows obtaining tokens via email/password without browser interaction
98+
99+
// GET credentials info
100+
fastify.get('/idp/credentials', async (request, reply) => {
101+
return handleCredentialsInfo(request, reply, issuer);
102+
});
103+
104+
// POST credentials - obtain tokens
105+
fastify.post('/idp/credentials', async (request, reply) => {
106+
return handleCredentials(request, reply, issuer);
107+
});
108+
92109
// Interaction routes (our custom login/consent UI)
93110
// These bypass oidc-provider and use our handlers
94111

0 commit comments

Comments
 (0)