javascript-solid-server 0.0.10 → 0.0.12

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,180 @@
1
+ /**
2
+ * Interaction handlers for login and consent flows
3
+ * Handles the user-facing parts of the authentication flow
4
+ */
5
+
6
+ import { authenticate, findById } from './accounts.js';
7
+ import { loginPage, consentPage, errorPage } from './views.js';
8
+
9
+ /**
10
+ * Handle GET /idp/interaction/:uid
11
+ * Shows login or consent page based on interaction state
12
+ */
13
+ export async function handleInteractionGet(request, reply, provider) {
14
+ const { uid } = request.params;
15
+
16
+ try {
17
+ const interaction = await provider.Interaction.find(uid);
18
+ if (!interaction) {
19
+ return reply.code(404).type('text/html').send(errorPage('Interaction not found', 'This login session has expired. Please try again.'));
20
+ }
21
+
22
+ const { prompt, params, session } = interaction;
23
+
24
+ // If we need login
25
+ if (prompt.name === 'login') {
26
+ return reply.type('text/html').send(loginPage(uid, params.client_id, interaction.lastError));
27
+ }
28
+
29
+ // If we need consent
30
+ if (prompt.name === 'consent') {
31
+ const client = await provider.Client.find(params.client_id);
32
+ const account = session?.accountId ? await findById(session.accountId) : null;
33
+
34
+ return reply.type('text/html').send(consentPage(uid, client, params, account));
35
+ }
36
+
37
+ // Unknown prompt
38
+ return reply.code(400).type('text/html').send(errorPage('Unknown prompt', `Unexpected prompt: ${prompt.name}`));
39
+ } catch (err) {
40
+ request.log.error(err, 'Interaction error');
41
+ return reply.code(500).type('text/html').send(errorPage('Server Error', err.message));
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Handle POST /idp/interaction/:uid/login
47
+ * Processes login form submission
48
+ */
49
+ export async function handleLogin(request, reply, provider) {
50
+ const { uid } = request.params;
51
+ const { email, password } = request.body || {};
52
+
53
+ try {
54
+ const interaction = await provider.Interaction.find(uid);
55
+ if (!interaction) {
56
+ return reply.code(404).type('text/html').send(errorPage('Session expired', 'Please try logging in again.'));
57
+ }
58
+
59
+ // Validate input
60
+ if (!email || !password) {
61
+ interaction.lastError = 'Email and password are required';
62
+ await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
63
+ return reply.redirect(`/idp/interaction/${uid}`);
64
+ }
65
+
66
+ // Authenticate
67
+ const account = await authenticate(email, password);
68
+ if (!account) {
69
+ interaction.lastError = 'Invalid email or password';
70
+ await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
71
+ return reply.redirect(`/idp/interaction/${uid}`);
72
+ }
73
+
74
+ // Login successful - complete the interaction
75
+ const result = {
76
+ login: {
77
+ accountId: account.id,
78
+ remember: true,
79
+ },
80
+ };
81
+
82
+ const redirectTo = await provider.interactionResult(
83
+ request.raw,
84
+ reply.raw,
85
+ result,
86
+ { mergeWithLastSubmission: false }
87
+ );
88
+
89
+ return reply.redirect(redirectTo);
90
+ } catch (err) {
91
+ request.log.error(err, 'Login error');
92
+ return reply.code(500).type('text/html').send(errorPage('Login failed', err.message));
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Handle POST /idp/interaction/:uid/confirm
98
+ * Processes consent confirmation
99
+ */
100
+ export async function handleConsent(request, reply, provider) {
101
+ const { uid } = request.params;
102
+
103
+ try {
104
+ const interaction = await provider.Interaction.find(uid);
105
+ if (!interaction) {
106
+ return reply.code(404).type('text/html').send(errorPage('Session expired', 'Please try again.'));
107
+ }
108
+
109
+ const { prompt, params, session } = interaction;
110
+ if (prompt.name !== 'consent') {
111
+ return reply.code(400).type('text/html').send(errorPage('Invalid state', 'Not in consent stage.'));
112
+ }
113
+
114
+ // Grant consent
115
+ const grant = new provider.Grant({
116
+ accountId: session.accountId,
117
+ clientId: params.client_id,
118
+ });
119
+
120
+ // Grant requested scopes
121
+ if (params.scope) {
122
+ grant.addOIDCScope(params.scope);
123
+ }
124
+
125
+ // Grant resource-specific scopes if present
126
+ if (params.resource) {
127
+ const resources = Array.isArray(params.resource) ? params.resource : [params.resource];
128
+ for (const resource of resources) {
129
+ grant.addResourceScope(resource, params.scope);
130
+ }
131
+ }
132
+
133
+ const grantId = await grant.save();
134
+
135
+ const result = {
136
+ consent: {
137
+ grantId,
138
+ },
139
+ };
140
+
141
+ const redirectTo = await provider.interactionResult(
142
+ request.raw,
143
+ reply.raw,
144
+ result,
145
+ { mergeWithLastSubmission: true }
146
+ );
147
+
148
+ return reply.redirect(redirectTo);
149
+ } catch (err) {
150
+ request.log.error(err, 'Consent error');
151
+ return reply.code(500).type('text/html').send(errorPage('Consent failed', err.message));
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Handle POST /idp/interaction/:uid/abort
157
+ * User cancelled the flow
158
+ */
159
+ export async function handleAbort(request, reply, provider) {
160
+ const { uid } = request.params;
161
+
162
+ try {
163
+ const result = {
164
+ error: 'access_denied',
165
+ error_description: 'User cancelled the authorization request',
166
+ };
167
+
168
+ const redirectTo = await provider.interactionResult(
169
+ request.raw,
170
+ reply.raw,
171
+ result,
172
+ { mergeWithLastSubmission: false }
173
+ );
174
+
175
+ return reply.redirect(redirectTo);
176
+ } catch (err) {
177
+ request.log.error(err, 'Abort error');
178
+ return reply.code(500).type('text/html').send(errorPage('Error', err.message));
179
+ }
180
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * JWKS key management for the Identity Provider
3
+ * Generates and stores signing keys for tokens
4
+ */
5
+
6
+ import * as jose from 'jose';
7
+ import fs from 'fs-extra';
8
+ import path from 'path';
9
+ import crypto from 'crypto';
10
+
11
+ /**
12
+ * Get keys directory (dynamic to support changing DATA_ROOT)
13
+ */
14
+ function getKeysDir() {
15
+ const dataRoot = process.env.DATA_ROOT || './data';
16
+ return path.join(dataRoot, '.idp', 'keys');
17
+ }
18
+
19
+ function getJwksPath() {
20
+ return path.join(getKeysDir(), 'jwks.json');
21
+ }
22
+
23
+ /**
24
+ * Generate a new EC P-256 key pair for signing
25
+ * @returns {Promise<object>} - JWK key pair with private key
26
+ */
27
+ async function generateSigningKey() {
28
+ const { publicKey, privateKey } = await jose.generateKeyPair('ES256', {
29
+ extractable: true,
30
+ });
31
+
32
+ const privateJwk = await jose.exportJWK(privateKey);
33
+ const publicJwk = await jose.exportJWK(publicKey);
34
+
35
+ // Add metadata
36
+ const kid = crypto.randomUUID();
37
+ const now = Math.floor(Date.now() / 1000);
38
+
39
+ return {
40
+ ...privateJwk,
41
+ kid,
42
+ use: 'sig',
43
+ alg: 'ES256',
44
+ iat: now,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Generate cookie signing keys
50
+ * @returns {string[]} - Array of random secret strings
51
+ */
52
+ function generateCookieKeys() {
53
+ return [
54
+ crypto.randomBytes(32).toString('base64url'),
55
+ crypto.randomBytes(32).toString('base64url'),
56
+ ];
57
+ }
58
+
59
+ /**
60
+ * Initialize JWKS - generate keys if they don't exist
61
+ * @returns {Promise<object>} - { jwks, cookieKeys }
62
+ */
63
+ export async function initializeKeys() {
64
+ await fs.ensureDir(getKeysDir());
65
+
66
+ try {
67
+ // Try to load existing keys
68
+ const data = await fs.readJson(getJwksPath());
69
+ return data;
70
+ } catch (err) {
71
+ if (err.code !== 'ENOENT') throw err;
72
+
73
+ // Generate new keys
74
+ console.log('Generating new IdP signing keys...');
75
+ const signingKey = await generateSigningKey();
76
+ const cookieKeys = generateCookieKeys();
77
+
78
+ const data = {
79
+ jwks: {
80
+ keys: [signingKey],
81
+ },
82
+ cookieKeys,
83
+ createdAt: new Date().toISOString(),
84
+ };
85
+
86
+ await fs.writeJson(getJwksPath(), data, { spaces: 2 });
87
+ console.log('IdP signing keys generated and saved.');
88
+
89
+ return data;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Get the JWKS (public keys only) for /.well-known/jwks.json
95
+ * @returns {Promise<object>} - JWKS with public keys only
96
+ */
97
+ export async function getPublicJwks() {
98
+ const { jwks } = await initializeKeys();
99
+
100
+ // Return only public key components
101
+ const publicKeys = jwks.keys.map((key) => {
102
+ // For EC keys, remove 'd' (private key component)
103
+ const { d, ...publicKey } = key;
104
+ return publicKey;
105
+ });
106
+
107
+ return { keys: publicKeys };
108
+ }
109
+
110
+ /**
111
+ * Get the full JWKS (including private keys) for oidc-provider
112
+ * @returns {Promise<object>} - Full JWKS
113
+ */
114
+ export async function getJwks() {
115
+ const { jwks } = await initializeKeys();
116
+ return jwks;
117
+ }
118
+
119
+ /**
120
+ * Get cookie signing keys
121
+ * @returns {Promise<string[]>} - Cookie keys
122
+ */
123
+ export async function getCookieKeys() {
124
+ const { cookieKeys } = await initializeKeys();
125
+ return cookieKeys;
126
+ }
127
+
128
+ /**
129
+ * Rotate signing keys (add new key, keep old for verification)
130
+ * @returns {Promise<void>}
131
+ */
132
+ export async function rotateKeys() {
133
+ const data = await fs.readJson(getJwksPath());
134
+
135
+ // Mark old keys
136
+ data.jwks.keys.forEach((key) => {
137
+ if (!key.rotatedAt) {
138
+ key.rotatedAt = new Date().toISOString();
139
+ }
140
+ });
141
+
142
+ // Generate new signing key
143
+ const newKey = await generateSigningKey();
144
+ data.jwks.keys.unshift(newKey); // New key first (primary)
145
+
146
+ // Keep only last 3 keys for verification
147
+ if (data.jwks.keys.length > 3) {
148
+ data.jwks.keys = data.jwks.keys.slice(0, 3);
149
+ }
150
+
151
+ // Rotate cookie keys too
152
+ data.cookieKeys = generateCookieKeys();
153
+ data.rotatedAt = new Date().toISOString();
154
+
155
+ await fs.writeJson(getJwksPath(), data, { spaces: 2 });
156
+ console.log('IdP keys rotated.');
157
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * oidc-provider configuration for Solid-OIDC
3
+ * Configures the OpenID Connect provider with DPoP support and webid claim
4
+ */
5
+
6
+ import Provider from 'oidc-provider';
7
+ import { createAdapter } from './adapter.js';
8
+ import { getJwks, getCookieKeys } from './keys.js';
9
+ import { getAccountForProvider } from './accounts.js';
10
+
11
+ /**
12
+ * Create and configure the OIDC provider
13
+ * @param {string} issuer - The issuer URL (e.g., 'https://example.com')
14
+ * @returns {Promise<Provider>} - Configured oidc-provider instance
15
+ */
16
+ export async function createProvider(issuer) {
17
+ const jwks = await getJwks();
18
+ const cookieKeys = await getCookieKeys();
19
+
20
+ const configuration = {
21
+ // Use our filesystem adapter
22
+ adapter: createAdapter,
23
+
24
+ // Signing keys
25
+ jwks,
26
+
27
+ // Cookie configuration
28
+ cookies: {
29
+ keys: cookieKeys,
30
+ long: {
31
+ signed: true,
32
+ maxAge: 14 * 24 * 60 * 60 * 1000, // 14 days
33
+ httpOnly: true,
34
+ sameSite: 'lax',
35
+ },
36
+ short: {
37
+ signed: true,
38
+ httpOnly: true,
39
+ sameSite: 'lax',
40
+ },
41
+ },
42
+
43
+ // Token TTLs
44
+ ttl: {
45
+ AccessToken: 3600, // 1 hour
46
+ AuthorizationCode: 600, // 10 minutes
47
+ IdToken: 3600, // 1 hour
48
+ RefreshToken: 14 * 24 * 3600, // 14 days
49
+ Interaction: 3600, // 1 hour
50
+ Session: 14 * 24 * 3600, // 14 days
51
+ Grant: 14 * 24 * 3600, // 14 days
52
+ },
53
+
54
+ // Features - configure for Solid-OIDC
55
+ features: {
56
+ // Disable dev interactions - we provide our own
57
+ devInteractions: {
58
+ enabled: false,
59
+ },
60
+
61
+ // DPoP is REQUIRED for Solid-OIDC
62
+ dPoP: {
63
+ enabled: true,
64
+ },
65
+
66
+ // Dynamic client registration (Solid apps need this)
67
+ registration: {
68
+ enabled: true,
69
+ idFactory: () => {
70
+ // Generate random client ID
71
+ return `client_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
72
+ },
73
+ initialAccessToken: false, // Allow public registration
74
+ policies: undefined, // No restrictions
75
+ },
76
+
77
+ // Client credentials for machine-to-machine
78
+ clientCredentials: {
79
+ enabled: true,
80
+ },
81
+
82
+ // Token introspection for resource servers
83
+ introspection: {
84
+ enabled: true,
85
+ },
86
+
87
+ // Token revocation
88
+ revocation: {
89
+ enabled: true,
90
+ },
91
+
92
+ // Device flow (optional, but useful for CLI apps)
93
+ deviceFlow: {
94
+ enabled: false, // Keep disabled for MVP
95
+ },
96
+
97
+ // Allow resource parameter
98
+ resourceIndicators: {
99
+ enabled: true,
100
+ defaultResource: () => undefined,
101
+ getResourceServerInfo: () => ({
102
+ scope: 'openid webid profile email offline_access',
103
+ accessTokenFormat: 'jwt',
104
+ }),
105
+ useGrantedResource: () => true,
106
+ },
107
+
108
+ // userinfo endpoint
109
+ userinfo: {
110
+ enabled: true,
111
+ },
112
+
113
+ // Allow backchannel logout
114
+ backchannelLogout: {
115
+ enabled: false,
116
+ },
117
+
118
+ // RP-initiated logout
119
+ rpInitiatedLogout: {
120
+ enabled: true,
121
+ postLogoutSuccessSource: async (ctx) => {
122
+ ctx.body = `
123
+ <!DOCTYPE html>
124
+ <html>
125
+ <head><title>Logged Out</title></head>
126
+ <body style="font-family: sans-serif; text-align: center; padding: 50px;">
127
+ <h1>You have been logged out</h1>
128
+ <p>You can close this window.</p>
129
+ </body>
130
+ </html>
131
+ `;
132
+ },
133
+ },
134
+ },
135
+
136
+ // Token format - JWT for Solid-OIDC
137
+ formats: {
138
+ AccessToken: 'jwt',
139
+ ClientCredentials: 'jwt',
140
+ },
141
+
142
+ // Scopes supported
143
+ scopes: ['openid', 'webid', 'profile', 'email', 'offline_access'],
144
+
145
+ // Claims configuration
146
+ claims: {
147
+ openid: ['sub'],
148
+ webid: ['webid'],
149
+ profile: ['name'],
150
+ email: ['email', 'email_verified'],
151
+ },
152
+
153
+ // Find account by ID (for token generation)
154
+ findAccount: async (ctx, id) => {
155
+ return getAccountForProvider(id);
156
+ },
157
+
158
+ // Extra access token claims for Solid-OIDC
159
+ extraTokenClaims: async (ctx, token) => {
160
+ if (token.accountId) {
161
+ const account = await getAccountForProvider(token.accountId);
162
+ if (account) {
163
+ const claims = await account.claims('access_token', token.scopes, {}, []);
164
+ return {
165
+ webid: claims.webid,
166
+ };
167
+ }
168
+ }
169
+ return {};
170
+ },
171
+
172
+ // Interaction URL for login/consent
173
+ interactions: {
174
+ url: (ctx, interaction) => {
175
+ return `/idp/interaction/${interaction.uid}`;
176
+ },
177
+ },
178
+
179
+ // Enable refresh token rotation
180
+ rotateRefreshToken: (ctx) => {
181
+ return true;
182
+ },
183
+
184
+ // Client defaults
185
+ clientDefaults: {
186
+ grant_types: ['authorization_code', 'refresh_token'],
187
+ response_types: ['code'],
188
+ token_endpoint_auth_method: 'none', // Public clients by default
189
+ },
190
+
191
+ // Response modes
192
+ responseModes: ['query', 'fragment', 'form_post'],
193
+
194
+ // Subject types
195
+ subjectTypes: ['public'],
196
+
197
+ // PKCE methods - require PKCE for public clients
198
+ pkceMethods: ['S256'],
199
+ pkce: {
200
+ required: () => true,
201
+ methods: ['S256'],
202
+ },
203
+
204
+ // Enable request parameter
205
+ requestObjects: {
206
+ request: false,
207
+ requestUri: false,
208
+ },
209
+
210
+ // Clock tolerance for token validation
211
+ clockTolerance: 60, // 60 seconds
212
+
213
+ // Render errors
214
+ renderError: async (ctx, out, error) => {
215
+ ctx.type = 'html';
216
+ ctx.body = `
217
+ <!DOCTYPE html>
218
+ <html>
219
+ <head>
220
+ <title>Error</title>
221
+ <style>
222
+ body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; padding: 40px; max-width: 600px; margin: 0 auto; }
223
+ .error { background: #fee; border: 1px solid #fcc; padding: 20px; border-radius: 8px; }
224
+ h1 { color: #c00; margin-top: 0; }
225
+ pre { background: #f5f5f5; padding: 10px; overflow-x: auto; }
226
+ </style>
227
+ </head>
228
+ <body>
229
+ <div class="error">
230
+ <h1>Authentication Error</h1>
231
+ <p><strong>${out.error}</strong></p>
232
+ <p>${out.error_description || ''}</p>
233
+ </div>
234
+ </body>
235
+ </html>
236
+ `;
237
+ },
238
+ };
239
+
240
+ const provider = new Provider(issuer, configuration);
241
+
242
+ // Allow localhost for development
243
+ provider.proxy = true;
244
+
245
+ return provider;
246
+ }