javascript-solid-server 0.0.12 → 0.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 } from './accounts.js';
9
+ import { getJwks } from './keys.js';
10
+
11
+ /**
12
+ * Handle POST /idp/credentials
13
+ * Accepts email/password (or username/password) and returns access token
14
+ *
15
+ * Request body (JSON or form):
16
+ * - email or username: User email address
17
+ * - password: User password
18
+ *
19
+ * Optional headers:
20
+ * - DPoP: DPoP proof JWT (for DPoP-bound tokens)
21
+ *
22
+ * Response:
23
+ * - access_token: JWT access token with webid claim
24
+ * - token_type: 'DPoP' or 'Bearer'
25
+ * - expires_in: Token lifetime in seconds
26
+ * - webid: User's WebID
27
+ */
28
+ export async function handleCredentials(request, reply, issuer) {
29
+ // Parse body (JSON or form-encoded)
30
+ let email, password;
31
+
32
+ const contentType = request.headers['content-type'] || '';
33
+ let body = request.body;
34
+
35
+ // Convert buffer to string if needed
36
+ if (Buffer.isBuffer(body)) {
37
+ body = body.toString('utf-8');
38
+ }
39
+
40
+ if (contentType.includes('application/json')) {
41
+ // JSON - Fastify parses this automatically
42
+ if (typeof body === 'string') {
43
+ try {
44
+ body = JSON.parse(body);
45
+ } catch {
46
+ // Not valid JSON
47
+ }
48
+ }
49
+ email = body?.email || body?.username;
50
+ password = body?.password;
51
+ } else if (contentType.includes('application/x-www-form-urlencoded')) {
52
+ // Parse form-encoded body
53
+ if (typeof body === 'string') {
54
+ const params = new URLSearchParams(body);
55
+ email = params.get('email') || params.get('username');
56
+ password = params.get('password');
57
+ } else if (typeof body === 'object') {
58
+ email = body?.email || body?.username;
59
+ password = body?.password;
60
+ }
61
+ } else {
62
+ // Try to parse as object
63
+ if (typeof body === 'object') {
64
+ email = body?.email || body?.username;
65
+ password = body?.password;
66
+ }
67
+ }
68
+
69
+ // Validate input
70
+ if (!email || !password) {
71
+ return reply.code(400).send({
72
+ error: 'invalid_request',
73
+ error_description: 'Username/email and password are required',
74
+ });
75
+ }
76
+
77
+ // Authenticate
78
+ const account = await authenticate(email, password);
79
+
80
+ if (!account) {
81
+ return reply.code(401).send({
82
+ error: 'invalid_grant',
83
+ error_description: 'Invalid email or password',
84
+ });
85
+ }
86
+
87
+ // Check for DPoP header
88
+ const dpopHeader = request.headers['dpop'];
89
+ let dpopJkt = null;
90
+
91
+ if (dpopHeader) {
92
+ try {
93
+ // Validate DPoP proof and extract thumbprint
94
+ const credUrl = `${issuer.replace(/\/$/, '')}/idp/credentials`;
95
+ dpopJkt = await validateDpopProof(dpopHeader, 'POST', credUrl);
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
+
106
+ // Always generate a proper JWT - CTH requires JWT format
107
+ const jwks = await getJwks();
108
+ const signingKey = jwks.keys[0];
109
+ const privateKey = await jose.importJWK(signingKey, 'ES256');
110
+
111
+ const now = Math.floor(Date.now() / 1000);
112
+ const tokenPayload = {
113
+ iss: issuer,
114
+ sub: account.id,
115
+ aud: 'solid', // Solid-OIDC requires this audience
116
+ webid: account.webId,
117
+ iat: now,
118
+ exp: now + expiresIn,
119
+ jti: crypto.randomUUID(),
120
+ client_id: 'credentials_client',
121
+ scope: 'openid webid',
122
+ };
123
+
124
+ // Add DPoP binding confirmation if DPoP proof was provided
125
+ let tokenType;
126
+ if (dpopJkt) {
127
+ tokenPayload.cnf = { jkt: dpopJkt };
128
+ tokenType = 'DPoP';
129
+ } else {
130
+ tokenType = 'Bearer';
131
+ }
132
+
133
+ const accessToken = await new jose.SignJWT(tokenPayload)
134
+ .setProtectedHeader({ alg: 'ES256', kid: signingKey.kid })
135
+ .sign(privateKey);
136
+
137
+ // Response
138
+ const response = {
139
+ access_token: accessToken,
140
+ token_type: tokenType,
141
+ expires_in: expiresIn,
142
+ webid: account.webId,
143
+ id: account.id,
144
+ };
145
+
146
+ reply.header('Cache-Control', 'no-store');
147
+ reply.header('Pragma', 'no-cache');
148
+
149
+ return response;
150
+ }
151
+
152
+ /**
153
+ * Validate a DPoP proof and return the JWK thumbprint
154
+ * @param {string} proof - The DPoP proof JWT
155
+ * @param {string} method - HTTP method
156
+ * @param {string} url - Request URL
157
+ * @returns {Promise<string>} - JWK thumbprint
158
+ */
159
+ async function validateDpopProof(proof, method, url) {
160
+ // Decode the proof header to get the public key
161
+ const protectedHeader = jose.decodeProtectedHeader(proof);
162
+
163
+ // DPoP proofs must have a JWK in the header
164
+ if (!protectedHeader.jwk) {
165
+ throw new Error('DPoP proof must contain jwk in header');
166
+ }
167
+
168
+ // Verify the proof signature
169
+ const publicKey = await jose.importJWK(protectedHeader.jwk, protectedHeader.alg);
170
+
171
+ let payload;
172
+ try {
173
+ const result = await jose.jwtVerify(proof, publicKey, {
174
+ typ: 'dpop+jwt',
175
+ maxTokenAge: '60s',
176
+ });
177
+ payload = result.payload;
178
+ } catch (err) {
179
+ throw new Error(`DPoP proof verification failed: ${err.message}`);
180
+ }
181
+
182
+ // Verify htm (HTTP method)
183
+ if (payload.htm !== method) {
184
+ throw new Error(`DPoP htm mismatch: expected ${method}, got ${payload.htm}`);
185
+ }
186
+
187
+ // Verify htu (HTTP URL) - compare without query string
188
+ const proofUrl = new URL(payload.htu);
189
+ const requestUrl = new URL(url);
190
+ if (proofUrl.origin + proofUrl.pathname !== requestUrl.origin + requestUrl.pathname) {
191
+ throw new Error('DPoP htu mismatch');
192
+ }
193
+
194
+ // Calculate JWK thumbprint
195
+ const thumbprint = await jose.calculateJwkThumbprint(protectedHeader.jwk, 'sha256');
196
+
197
+ return thumbprint;
198
+ }
199
+
200
+ /**
201
+ * Handle GET /idp/credentials
202
+ * Returns info about the credentials endpoint
203
+ */
204
+ export function handleCredentialsInfo(request, reply, issuer) {
205
+ return {
206
+ endpoint: `${issuer}/idp/credentials`,
207
+ method: 'POST',
208
+ description: 'Obtain access tokens using email/username and password',
209
+ content_types: ['application/json', 'application/x-www-form-urlencoded'],
210
+ parameters: {
211
+ email: 'User email address (or use "username")',
212
+ username: 'Alias for email (for CTH compatibility)',
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
+ }
package/src/idp/index.js CHANGED
@@ -12,6 +12,10 @@ import {
12
12
  handleConsent,
13
13
  handleAbort,
14
14
  } from './interactions.js';
15
+ import {
16
+ handleCredentials,
17
+ handleCredentialsInfo,
18
+ } from './credentials.js';
15
19
 
16
20
  /**
17
21
  * IdP Fastify Plugin
@@ -32,38 +36,123 @@ export async function idpPlugin(fastify, options) {
32
36
  // Create the OIDC provider
33
37
  const provider = await createProvider(issuer);
34
38
 
39
+ // Add error listener to catch internal oidc-provider errors
40
+ provider.on('server_error', (ctx, err) => {
41
+ fastify.log.error({
42
+ err: err.message,
43
+ stack: err.stack,
44
+ path: ctx?.path,
45
+ cause: err.cause?.message,
46
+ error_description: err.error_description,
47
+ }, 'oidc-provider server error');
48
+ });
49
+ provider.on('grant.error', (ctx, err) => {
50
+ fastify.log.error({
51
+ err: err.message,
52
+ stack: err.stack?.substring(0, 800),
53
+ cause: err.cause?.message,
54
+ error_description: err.error_description,
55
+ }, 'oidc-provider grant error');
56
+ });
57
+
35
58
  // Store provider reference on fastify for handlers
36
59
  fastify.decorate('oidcProvider', provider);
37
60
 
38
61
  // Register middleware support for oidc-provider (Koa app)
39
- await fastify.register(middie, {
40
- hook: 'preHandler',
62
+ await fastify.register(middie);
63
+
64
+ // Helper to forward requests to oidc-provider
65
+ const forwardToProvider = async (request, reply) => {
66
+ return new Promise((resolve, reject) => {
67
+ // Get raw Node.js req/res
68
+ const req = request.raw;
69
+ const res = reply.raw;
70
+
71
+ // oidc-provider is now configured with /idp routes, no stripping needed
72
+ // Ensure parsed body is accessible to oidc-provider
73
+ // Fastify parses body into request.body, oidc-provider looks for req.body
74
+ if (request.body !== undefined) {
75
+ if (Buffer.isBuffer(request.body)) {
76
+ // Parse buffer to object if it's JSON
77
+ const contentType = request.headers['content-type'] || '';
78
+ if (contentType.includes('application/json')) {
79
+ try {
80
+ req.body = JSON.parse(request.body.toString());
81
+ } catch (e) {
82
+ req.body = request.body;
83
+ }
84
+ } else if (contentType.includes('application/x-www-form-urlencoded')) {
85
+ // Parse form data
86
+ const params = new URLSearchParams(request.body.toString());
87
+ req.body = Object.fromEntries(params.entries());
88
+ } else {
89
+ req.body = request.body;
90
+ }
91
+ } else {
92
+ req.body = request.body;
93
+ }
94
+ }
95
+
96
+ // Call oidc-provider's callback
97
+ provider.callback()(req, res);
98
+
99
+ // Wait for response to finish
100
+ res.on('finish', resolve);
101
+ res.on('error', reject);
102
+ });
103
+ };
104
+
105
+ // Legacy handler for /auth/:uid without prefix - redirect to /idp/auth/:uid
106
+ // In case any old redirects or cached URLs exist
107
+ fastify.get('/auth/:uid', async (request, reply) => {
108
+ return reply.redirect(`/idp/auth/${request.params.uid}`);
109
+ });
110
+
111
+ // Catch-all route for oidc-provider paths
112
+ // Must be registered BEFORE specific routes to be matched as fallback
113
+ const oidcPaths = ['/idp/auth', '/idp/token', '/idp/reg', '/idp/me', '/idp/session', '/idp/session/*'];
114
+
115
+ for (const path of oidcPaths) {
116
+ fastify.route({
117
+ method: ['GET', 'POST', 'DELETE'],
118
+ url: path,
119
+ handler: forwardToProvider,
120
+ });
121
+ }
122
+
123
+ // Also handle /idp/auth/:uid for continued authorization after login
124
+ fastify.get('/idp/auth/:uid', forwardToProvider);
125
+
126
+ // Token sub-paths
127
+ fastify.route({
128
+ method: ['GET', 'POST'],
129
+ url: '/idp/token/introspection',
130
+ handler: forwardToProvider,
41
131
  });
42
132
 
43
- // Mount oidc-provider on /idp path
44
- // oidc-provider is a Koa app, middie handles the bridge
45
- fastify.use('/idp', (req, res, next) => {
46
- // Skip our custom interaction routes
47
- if (req.url.startsWith('/interaction/')) {
48
- return next();
49
- }
50
- // Let oidc-provider handle everything else
51
- provider.callback()(req, res);
133
+ fastify.route({
134
+ method: ['GET', 'POST'],
135
+ url: '/idp/token/revocation',
136
+ handler: forwardToProvider,
52
137
  });
53
138
 
54
139
  // /.well-known/openid-configuration
55
140
  fastify.get('/.well-known/openid-configuration', async (request, reply) => {
141
+ // Ensure issuer has trailing slash for CTH compatibility
142
+ const normalizedIssuer = issuer.endsWith('/') ? issuer : issuer + '/';
143
+ // Base URL without trailing slash for building endpoint URLs
144
+ const baseUrl = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer;
56
145
  // Build discovery document
57
146
  const config = {
58
- issuer,
59
- authorization_endpoint: `${issuer}/idp/auth`,
60
- token_endpoint: `${issuer}/idp/token`,
61
- userinfo_endpoint: `${issuer}/idp/me`,
62
- jwks_uri: `${issuer}/.well-known/jwks.json`,
63
- registration_endpoint: `${issuer}/idp/reg`,
64
- introspection_endpoint: `${issuer}/idp/token/introspection`,
65
- revocation_endpoint: `${issuer}/idp/token/revocation`,
66
- end_session_endpoint: `${issuer}/idp/session/end`,
147
+ issuer: normalizedIssuer,
148
+ authorization_endpoint: `${baseUrl}/idp/auth`,
149
+ token_endpoint: `${baseUrl}/idp/token`,
150
+ userinfo_endpoint: `${baseUrl}/idp/me`,
151
+ jwks_uri: `${baseUrl}/.well-known/jwks.json`,
152
+ registration_endpoint: `${baseUrl}/idp/reg`,
153
+ introspection_endpoint: `${baseUrl}/idp/token/introspection`,
154
+ revocation_endpoint: `${baseUrl}/idp/token/revocation`,
155
+ end_session_endpoint: `${baseUrl}/idp/session/end`,
67
156
  scopes_supported: ['openid', 'webid', 'profile', 'email', 'offline_access'],
68
157
  response_types_supported: ['code'],
69
158
  response_modes_supported: ['query', 'fragment', 'form_post'],
@@ -89,6 +178,19 @@ export async function idpPlugin(fastify, options) {
89
178
  return jwks;
90
179
  });
91
180
 
181
+ // Programmatic credentials endpoint for CTH compatibility
182
+ // Allows obtaining tokens via email/password without browser interaction
183
+
184
+ // GET credentials info
185
+ fastify.get('/idp/credentials', async (request, reply) => {
186
+ return handleCredentialsInfo(request, reply, issuer);
187
+ });
188
+
189
+ // POST credentials - obtain tokens
190
+ fastify.post('/idp/credentials', async (request, reply) => {
191
+ return handleCredentials(request, reply, issuer);
192
+ });
193
+
92
194
  // Interaction routes (our custom login/consent UI)
93
195
  // These bypass oidc-provider and use our handlers
94
196
 
@@ -97,7 +199,13 @@ export async function idpPlugin(fastify, options) {
97
199
  return handleInteractionGet(request, reply, provider);
98
200
  });
99
201
 
100
- // POST login
202
+ // POST interaction - direct form submission (CTH compatibility)
203
+ // This handles form submissions directly to /idp/interaction/:uid
204
+ fastify.post('/idp/interaction/:uid', async (request, reply) => {
205
+ return handleLogin(request, reply, provider);
206
+ });
207
+
208
+ // POST login (explicit path)
101
209
  fastify.post('/idp/interaction/:uid/login', async (request, reply) => {
102
210
  return handleLogin(request, reply, provider);
103
211
  });
@@ -48,7 +48,44 @@ export async function handleInteractionGet(request, reply, provider) {
48
48
  */
49
49
  export async function handleLogin(request, reply, provider) {
50
50
  const { uid } = request.params;
51
- const { email, password } = request.body || {};
51
+
52
+ // Parse body - handle multiple formats (Buffer, string, object)
53
+ let parsedBody = request.body || {};
54
+ const contentType = request.headers['content-type'] || '';
55
+
56
+ if (Buffer.isBuffer(parsedBody)) {
57
+ const bodyStr = parsedBody.toString();
58
+ if (contentType.includes('application/json')) {
59
+ try {
60
+ parsedBody = JSON.parse(bodyStr);
61
+ } catch (e) {
62
+ parsedBody = {};
63
+ }
64
+ } else {
65
+ // Assume form-urlencoded
66
+ const params = new URLSearchParams(bodyStr);
67
+ parsedBody = Object.fromEntries(params.entries());
68
+ }
69
+ } else if (typeof parsedBody === 'string') {
70
+ // Body might be a string for form-urlencoded
71
+ if (contentType.includes('application/json')) {
72
+ try {
73
+ parsedBody = JSON.parse(parsedBody);
74
+ } catch (e) {
75
+ parsedBody = {};
76
+ }
77
+ } else {
78
+ const params = new URLSearchParams(parsedBody);
79
+ parsedBody = Object.fromEntries(params.entries());
80
+ }
81
+ }
82
+ // If it's already an object, use as-is
83
+
84
+ // Support both 'email' and 'username' fields for CTH compatibility
85
+ const email = parsedBody.email || parsedBody.username;
86
+ const password = parsedBody.password;
87
+
88
+ request.log.info({ email, hasPassword: !!password, bodyType: typeof request.body, keys: Object.keys(parsedBody) }, 'Login attempt');
52
89
 
53
90
  try {
54
91
  const interaction = await provider.Interaction.find(uid);
@@ -79,14 +116,86 @@ export async function handleLogin(request, reply, provider) {
79
116
  },
80
117
  };
81
118
 
82
- const redirectTo = await provider.interactionResult(
83
- request.raw,
84
- reply.raw,
85
- result,
86
- { mergeWithLastSubmission: false }
87
- );
119
+ request.log.info({ accountId: account.id, uid }, 'Login successful');
88
120
 
89
- return reply.redirect(redirectTo);
121
+ // For CTH compatibility, we need to return a response that CTH can handle.
122
+ // CTH expects either:
123
+ // 1. A redirect it can follow (but Java HttpClient follows to final destination which fails)
124
+ // 2. A 200 response with "location" in body (CSS v3+ style)
125
+ //
126
+ // We use interactionResult to get the redirect URL, then save it and return JSON
127
+
128
+ // Save the login result to the interaction for programmatic clients
129
+ // This allows the auth endpoint to continue the flow when resumed
130
+ interaction.result = result;
131
+ await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
132
+
133
+ // For CTH and programmatic clients: use interactionFinished with hijacked response
134
+ // to properly complete the interaction while returning JSON
135
+ try {
136
+ reply.hijack();
137
+
138
+ // Create a mock response that captures the redirect and returns JSON
139
+ let capturedLocation = null;
140
+ let headersSent = false;
141
+ const mockRes = {
142
+ statusCode: 200,
143
+ headersSent: false,
144
+ setHeader: (name, value) => {
145
+ if (name.toLowerCase() === 'location') {
146
+ capturedLocation = value;
147
+ }
148
+ return mockRes;
149
+ },
150
+ getHeader: (name) => {
151
+ if (name.toLowerCase() === 'location') return capturedLocation;
152
+ return undefined;
153
+ },
154
+ removeHeader: () => mockRes,
155
+ writeHead: (status, headers) => {
156
+ if (headers) {
157
+ if (typeof headers === 'object' && !Array.isArray(headers)) {
158
+ for (const [key, value] of Object.entries(headers)) {
159
+ if (key.toLowerCase() === 'location') {
160
+ capturedLocation = value;
161
+ }
162
+ }
163
+ }
164
+ }
165
+ return mockRes;
166
+ },
167
+ write: () => mockRes,
168
+ end: (body) => {
169
+ if (!headersSent) {
170
+ headersSent = true;
171
+ const location = capturedLocation || `/idp/auth/${uid}`;
172
+ reply.raw.writeHead(200, {
173
+ 'Content-Type': 'application/json',
174
+ 'Location': location,
175
+ });
176
+ reply.raw.end(JSON.stringify({ location }));
177
+ }
178
+ },
179
+ finished: false,
180
+ on: () => mockRes,
181
+ once: () => mockRes,
182
+ emit: () => mockRes,
183
+ };
184
+
185
+ await provider.interactionFinished(request.raw, mockRes, result, { mergeWithLastSubmission: false });
186
+ return;
187
+ } catch (err) {
188
+ request.log.warn({ err: err.message, errName: err.name, uid }, 'interactionFinished failed, using fallback');
189
+
190
+ // Fallback: return the redirect URL for manual following
191
+ // The interaction result is already saved above
192
+ const redirectTo = `/idp/auth/${uid}`;
193
+ return reply
194
+ .code(200)
195
+ .header('Location', redirectTo)
196
+ .type('application/json')
197
+ .send({ location: redirectTo });
198
+ }
90
199
  } catch (err) {
91
200
  request.log.error(err, 'Login error');
92
201
  return reply.code(500).type('text/html').send(errorPage('Login failed', err.message));
@@ -138,14 +247,16 @@ export async function handleConsent(request, reply, provider) {
138
247
  },
139
248
  };
140
249
 
141
- const redirectTo = await provider.interactionResult(
250
+ // Mark reply as sent since interactionFinished will handle the response
251
+ reply.hijack();
252
+
253
+ // Use interactionFinished which handles the redirect directly
254
+ return provider.interactionFinished(
142
255
  request.raw,
143
256
  reply.raw,
144
257
  result,
145
258
  { mergeWithLastSubmission: true }
146
259
  );
147
-
148
- return reply.redirect(redirectTo);
149
260
  } catch (err) {
150
261
  request.log.error(err, 'Consent error');
151
262
  return reply.code(500).type('text/html').send(errorPage('Consent failed', err.message));
@@ -165,6 +276,7 @@ export async function handleAbort(request, reply, provider) {
165
276
  error_description: 'User cancelled the authorization request',
166
277
  };
167
278
 
279
+ // oidc-provider is configured with /idp routes, so redirectTo will have correct path
168
280
  const redirectTo = await provider.interactionResult(
169
281
  request.raw,
170
282
  reply.raw,
@@ -27,16 +27,19 @@ export async function createProvider(issuer) {
27
27
  // Cookie configuration
28
28
  cookies: {
29
29
  keys: cookieKeys,
30
+ // Use root path so cookies work across all endpoints
30
31
  long: {
31
32
  signed: true,
32
33
  maxAge: 14 * 24 * 60 * 60 * 1000, // 14 days
33
34
  httpOnly: true,
34
35
  sameSite: 'lax',
36
+ path: '/',
35
37
  },
36
38
  short: {
37
39
  signed: true,
38
40
  httpOnly: true,
39
41
  sameSite: 'lax',
42
+ path: '/',
40
43
  },
41
44
  },
42
45
 
@@ -94,13 +97,16 @@ export async function createProvider(issuer) {
94
97
  enabled: false, // Keep disabled for MVP
95
98
  },
96
99
 
97
- // Allow resource parameter
100
+ // Allow resource parameter - always use JWT format for access tokens
101
+ // Resource must be a valid URI, but audience can be 'solid' for Solid-OIDC
98
102
  resourceIndicators: {
99
103
  enabled: true,
100
- defaultResource: () => undefined,
104
+ // Default to a URI resource that maps to audience 'solid'
105
+ defaultResource: () => 'urn:solid',
101
106
  getResourceServerInfo: () => ({
102
107
  scope: 'openid webid profile email offline_access',
103
108
  accessTokenFormat: 'jwt',
109
+ audience: 'solid', // Solid-OIDC requires this audience
104
110
  }),
105
111
  useGrantedResource: () => true,
106
112
  },
@@ -176,6 +182,60 @@ export async function createProvider(issuer) {
176
182
  },
177
183
  },
178
184
 
185
+ // Auto-approve consent by loading/creating grants automatically
186
+ // This skips the consent prompt for all clients (appropriate for test/dev servers)
187
+ loadExistingGrant: async (ctx) => {
188
+ // Check if there's an existing grant for this client/account pair
189
+ const grantId = ctx.oidc.session?.grantIdFor(ctx.oidc.client?.clientId);
190
+
191
+ if (grantId) {
192
+ const existingGrant = await ctx.oidc.provider.Grant.find(grantId);
193
+ if (existingGrant) {
194
+ return existingGrant;
195
+ }
196
+ }
197
+
198
+ // Auto-approve: create a new grant with all requested scopes
199
+ if (ctx.oidc.session?.accountId && ctx.oidc.client?.clientId) {
200
+ const grant = new ctx.oidc.provider.Grant({
201
+ accountId: ctx.oidc.session.accountId,
202
+ clientId: ctx.oidc.client.clientId,
203
+ });
204
+
205
+ // Grant all requested OIDC scopes
206
+ if (ctx.oidc.params?.scope) {
207
+ grant.addOIDCScope(ctx.oidc.params.scope);
208
+ }
209
+
210
+ // Grant all requested resource scopes
211
+ if (ctx.oidc.params?.resource) {
212
+ const resources = Array.isArray(ctx.oidc.params.resource)
213
+ ? ctx.oidc.params.resource
214
+ : [ctx.oidc.params.resource];
215
+ for (const resource of resources) {
216
+ grant.addResourceScope(resource, ctx.oidc.params.scope || 'openid');
217
+ }
218
+ }
219
+
220
+ await grant.save();
221
+ return grant;
222
+ }
223
+
224
+ return undefined;
225
+ },
226
+
227
+ // Configure routes with /idp prefix so oidc-provider uses correct paths
228
+ routes: {
229
+ authorization: '/idp/auth',
230
+ token: '/idp/token',
231
+ userinfo: '/idp/me',
232
+ jwks: '/.well-known/jwks.json',
233
+ registration: '/idp/reg',
234
+ introspection: '/idp/token/introspection',
235
+ revocation: '/idp/token/revocation',
236
+ end_session: '/idp/session/end',
237
+ },
238
+
179
239
  // Enable refresh token rotation
180
240
  rotateRefreshToken: (ctx) => {
181
241
  return true;
@@ -186,6 +246,7 @@ export async function createProvider(issuer) {
186
246
  grant_types: ['authorization_code', 'refresh_token'],
187
247
  response_types: ['code'],
188
248
  token_endpoint_auth_method: 'none', // Public clients by default
249
+ id_token_signed_response_alg: 'ES256', // ES256 is what we support
189
250
  },
190
251
 
191
252
  // Response modes
@@ -201,6 +262,11 @@ export async function createProvider(issuer) {
201
262
  methods: ['S256'],
202
263
  },
203
264
 
265
+ // Enable RS256 for DPoP (CTH uses RS256)
266
+ enabledJWA: {
267
+ dPoPSigningAlgValues: ['ES256', 'RS256', 'Ed25519', 'EdDSA'],
268
+ },
269
+
204
270
  // Enable request parameter
205
271
  requestObjects: {
206
272
  request: false,