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.
- package/.claude/settings.local.json +11 -1
- package/README.md +110 -9
- package/bin/jss.js +226 -0
- package/package.json +11 -4
- package/src/config.js +192 -0
- package/src/handlers/container.js +35 -2
- package/src/handlers/resource.js +3 -1
- package/src/idp/accounts.js +258 -0
- package/src/idp/adapter.js +204 -0
- package/src/idp/index.js +118 -0
- package/src/idp/interactions.js +180 -0
- package/src/idp/keys.js +157 -0
- package/src/idp/provider.js +246 -0
- package/src/idp/views.js +295 -0
- package/src/server.js +39 -12
- package/test/conformance.test.js +349 -0
- package/test/idp.test.js +258 -0
|
@@ -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
|
+
}
|
package/src/idp/keys.js
ADDED
|
@@ -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
|
+
}
|