@flink-app/oidc-plugin 1.0.0 → 2.0.0-alpha.101
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/CHANGELOG.md +566 -0
- package/README.md +23 -2
- package/dist/OidcPlugin.d.ts +1 -1
- package/dist/OidcPlugin.d.ts.map +1 -1
- package/dist/OidcPlugin.js +32 -18
- package/dist/OidcPluginOptions.d.ts +75 -8
- package/dist/OidcPluginOptions.d.ts.map +1 -1
- package/dist/OidcProviderConfig.d.ts +21 -1
- package/dist/OidcProviderConfig.d.ts.map +1 -1
- package/dist/handlers/CallbackOidc.d.ts.map +1 -1
- package/dist/handlers/CallbackOidc.js +50 -5
- package/dist/handlers/InitiateOidc.d.ts.map +1 -1
- package/dist/handlers/InitiateOidc.js +14 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/log.d.ts +27 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +37 -0
- package/dist/providers/OidcProvider.d.ts +1 -1
- package/dist/providers/OidcProvider.d.ts.map +1 -1
- package/dist/providers/OidcProvider.js +46 -5
- package/dist/providers/ProviderRegistry.d.ts +2 -1
- package/dist/providers/ProviderRegistry.d.ts.map +1 -1
- package/dist/providers/ProviderRegistry.js +10 -2
- package/dist/schemas/CallbackRequest.d.ts +3 -6
- package/dist/schemas/CallbackRequest.d.ts.map +1 -1
- package/dist/schemas/InitiateRequest.d.ts +3 -6
- package/dist/schemas/InitiateRequest.d.ts.map +1 -1
- package/dist/schemas/OidcSession.d.ts +5 -0
- package/dist/schemas/OidcSession.d.ts.map +1 -1
- package/dist/utils/response-utils.d.ts +16 -5
- package/dist/utils/response-utils.d.ts.map +1 -1
- package/dist/utils/response-utils.js +31 -10
- package/examples/multi-provider.ts +1 -1
- package/package.json +8 -11
- package/spec/helpers/test-helpers.ts +14 -0
- package/spec/plugin/OidcPlugin.spec.ts +104 -3
- package/spec/providers/ProviderRegistry.spec.ts +8 -7
- package/spec/utils/response-utils.spec.ts +85 -0
- package/src/OidcPlugin.ts +54 -21
- package/src/OidcPluginOptions.ts +84 -9
- package/src/OidcProviderConfig.ts +23 -1
- package/src/handlers/CallbackOidc.ts +66 -5
- package/src/handlers/InitiateOidc.ts +19 -1
- package/src/index.ts +1 -1
- package/src/log.ts +33 -0
- package/src/providers/OidcProvider.ts +60 -7
- package/src/providers/ProviderRegistry.ts +22 -3
- package/src/schemas/CallbackRequest.ts +4 -6
- package/src/schemas/InitiateRequest.ts +3 -6
- package/src/schemas/OidcSession.ts +6 -0
- package/src/utils/response-utils.ts +53 -10
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"OidcProvider.d.ts","sourceRoot":"","sources":["../../src/providers/OidcProvider.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"OidcProvider.d.ts","sourceRoot":"","sources":["../../src/providers/OidcProvider.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwD,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACvG,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,WAAW,MAAM,wBAAwB,CAAC;AACjD,OAAO,YAAY,MAAM,yBAAyB,CAAC;AAKnD;;;;;;;;;;GAUG;AACH,qBAAa,YAAY;IACrB,SAAgB,MAAM,EAAE,kBAAkB,CAAC;IAC3C,OAAO,CAAC,MAAM,CAA+B;IAC7C,OAAO,CAAC,MAAM,CAAuB;IACrC,OAAO,CAAC,WAAW,CAAkB;gBAEzB,MAAM,EAAE,kBAAkB;IAItC;;;;;;;OAOG;IACG,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAsEjC;;;;;OAKG;IACG,mBAAmB,CAAC,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC;IAiB1G;;;;;;;OAOG;IACG,oBAAoB,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,YAAY,CAAC;IAoD/H;;;;;;;;OAQG;IACG,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAsBjE;;;;;;;;;OASG;IACG,YAAY,CAAC,QAAQ,EAAE,YAAY,EAAE,eAAe,GAAE,OAAc,GAAG,OAAO,CAAC,WAAW,CAAC;IAgCjG;;;;OAIG;YACW,iBAAiB;IAU/B;;;;OAIG;IACH,iBAAiB,IAAI,GAAG;CAM3B"}
|
|
@@ -4,6 +4,7 @@ exports.OidcProvider = void 0;
|
|
|
4
4
|
const openid_client_1 = require("openid-client");
|
|
5
5
|
const claims_mapper_1 = require("../utils/claims-mapper");
|
|
6
6
|
const error_utils_1 = require("../utils/error-utils");
|
|
7
|
+
const log_1 = require("../log");
|
|
7
8
|
/**
|
|
8
9
|
* Generic OIDC Provider implementation using openid-client
|
|
9
10
|
*
|
|
@@ -37,7 +38,10 @@ class OidcProvider {
|
|
|
37
38
|
try {
|
|
38
39
|
// Option 1: OIDC Discovery
|
|
39
40
|
if (this.config.discoveryUrl) {
|
|
41
|
+
log_1.oidcLog.debug(`Provider "${this.config.issuer}": discovering from ${this.config.discoveryUrl}`);
|
|
40
42
|
this.issuer = await openid_client_1.Issuer.discover(this.config.discoveryUrl);
|
|
43
|
+
log_1.oidcLog.debug(`Provider "${this.config.issuer}": discovery complete`, `authorization_endpoint=${this.issuer.metadata.authorization_endpoint}`, `token_endpoint=${this.issuer.metadata.token_endpoint}`, `userinfo_endpoint=${this.issuer.metadata.userinfo_endpoint ?? "(none)"}`, `jwks_uri=${this.issuer.metadata.jwks_uri}`);
|
|
44
|
+
log_1.oidcLog.trace(`Provider "${this.config.issuer}": raw discovery metadata from ${this.config.discoveryUrl}`, this.issuer.metadata);
|
|
41
45
|
}
|
|
42
46
|
// Option 2: Manual configuration
|
|
43
47
|
else {
|
|
@@ -54,12 +58,22 @@ class OidcProvider {
|
|
|
54
58
|
});
|
|
55
59
|
}
|
|
56
60
|
// Create OIDC client
|
|
57
|
-
|
|
61
|
+
const clientMetadata = {
|
|
58
62
|
client_id: this.config.clientId,
|
|
59
|
-
client_secret: this.config.clientSecret,
|
|
60
63
|
redirect_uris: [this.config.callbackUrl],
|
|
61
64
|
response_types: ["code"],
|
|
62
|
-
}
|
|
65
|
+
};
|
|
66
|
+
if (this.config.clientSecret) {
|
|
67
|
+
clientMetadata.client_secret = this.config.clientSecret;
|
|
68
|
+
clientMetadata.token_endpoint_auth_method =
|
|
69
|
+
this.config.tokenEndpointAuthMethod || "client_secret_basic";
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
// Public client (PKCE-only) — openid-client requires this to skip client authentication
|
|
73
|
+
clientMetadata.token_endpoint_auth_method =
|
|
74
|
+
this.config.tokenEndpointAuthMethod || "none";
|
|
75
|
+
}
|
|
76
|
+
this.client = new this.issuer.Client(clientMetadata);
|
|
63
77
|
this.initialized = true;
|
|
64
78
|
}
|
|
65
79
|
catch (error) {
|
|
@@ -107,8 +121,13 @@ class OidcProvider {
|
|
|
107
121
|
state: params.state,
|
|
108
122
|
nonce: params.nonce,
|
|
109
123
|
});
|
|
124
|
+
// Raw response from the token endpoint (includes access_token, id_token,
|
|
125
|
+
// refresh_token, token_type, expires_at, scope, and any non-standard fields
|
|
126
|
+
// returned by the IdP). Trace-level because it contains secrets.
|
|
127
|
+
log_1.oidcLog.trace(`Provider "${this.config.issuer}": raw token endpoint response`, { ...tokenSet });
|
|
110
128
|
// Extract claims from ID token (already validated by openid-client)
|
|
111
129
|
const claims = tokenSet.claims();
|
|
130
|
+
log_1.oidcLog.trace(`Provider "${this.config.issuer}": extracted claims from ID token`, claims);
|
|
112
131
|
return {
|
|
113
132
|
accessToken: tokenSet.access_token,
|
|
114
133
|
idToken: tokenSet.id_token,
|
|
@@ -120,6 +139,15 @@ class OidcProvider {
|
|
|
120
139
|
};
|
|
121
140
|
}
|
|
122
141
|
catch (error) {
|
|
142
|
+
// openid-client attaches the raw IdP error response on OPError.
|
|
143
|
+
log_1.oidcLog.trace(`Provider "${this.config.issuer}": raw token exchange error`, {
|
|
144
|
+
name: error.name,
|
|
145
|
+
message: error.message,
|
|
146
|
+
error: error.error,
|
|
147
|
+
error_description: error.error_description,
|
|
148
|
+
error_uri: error.error_uri,
|
|
149
|
+
response: error.response?.body ?? error.response,
|
|
150
|
+
});
|
|
123
151
|
throw (0, error_utils_1.createOidcError)(error_utils_1.OidcErrorCodes.TOKEN_EXCHANGE_FAILED, `Token exchange failed: ${error.message}`, {
|
|
124
152
|
originalError: error.message,
|
|
125
153
|
errorCode: error.error,
|
|
@@ -139,9 +167,18 @@ class OidcProvider {
|
|
|
139
167
|
await this.ensureInitialized();
|
|
140
168
|
try {
|
|
141
169
|
const userinfo = await this.client.userinfo(accessToken);
|
|
170
|
+
// Raw response from the UserInfo endpoint, before any mapping/merging.
|
|
171
|
+
log_1.oidcLog.trace(`Provider "${this.config.issuer}": raw UserInfo endpoint response`, userinfo);
|
|
142
172
|
return userinfo;
|
|
143
173
|
}
|
|
144
174
|
catch (error) {
|
|
175
|
+
log_1.oidcLog.trace(`Provider "${this.config.issuer}": raw UserInfo error`, {
|
|
176
|
+
name: error.name,
|
|
177
|
+
message: error.message,
|
|
178
|
+
error: error.error,
|
|
179
|
+
error_description: error.error_description,
|
|
180
|
+
response: error.response?.body ?? error.response,
|
|
181
|
+
});
|
|
145
182
|
throw (0, error_utils_1.createOidcError)(error_utils_1.OidcErrorCodes.USERINFO_FAILED, `UserInfo request failed: ${error.message}`, {
|
|
146
183
|
originalError: error.message,
|
|
147
184
|
});
|
|
@@ -160,7 +197,9 @@ class OidcProvider {
|
|
|
160
197
|
async buildProfile(tokenSet, includeUserInfo = true) {
|
|
161
198
|
let claims = { ...tokenSet.claims };
|
|
162
199
|
// Optionally fetch additional claims from UserInfo endpoint
|
|
163
|
-
|
|
200
|
+
// Check both manual config and discovered endpoint (via OIDC discovery)
|
|
201
|
+
const userinfoUrl = this.config.userinfoEndpoint || this.issuer?.metadata?.userinfo_endpoint;
|
|
202
|
+
if (includeUserInfo && userinfoUrl) {
|
|
164
203
|
try {
|
|
165
204
|
const userinfo = await this.getUserInfo(tokenSet.accessToken);
|
|
166
205
|
// Merge UserInfo claims with ID token claims
|
|
@@ -168,16 +207,18 @@ class OidcProvider {
|
|
|
168
207
|
}
|
|
169
208
|
catch (error) {
|
|
170
209
|
// UserInfo is optional - continue with ID token claims only
|
|
171
|
-
|
|
210
|
+
log_1.oidcLog.warn(`Failed to fetch UserInfo from ${userinfoUrl}, using ID token claims only:`, error);
|
|
172
211
|
}
|
|
173
212
|
}
|
|
174
213
|
// Apply custom claim mapping if configured
|
|
175
214
|
if (this.config.claimMapping) {
|
|
176
215
|
const customClaims = (0, claims_mapper_1.extractCustomClaims)(claims, this.config.claimMapping);
|
|
177
216
|
claims = { ...claims, ...customClaims };
|
|
217
|
+
log_1.oidcLog.trace(`Provider "${this.config.issuer}": applied custom claim mapping`, claims);
|
|
178
218
|
}
|
|
179
219
|
// Map to normalized profile
|
|
180
220
|
const profile = (0, claims_mapper_1.mapClaimsToProfile)(claims);
|
|
221
|
+
log_1.oidcLog.trace(`Provider "${this.config.issuer}": built final profile`, profile);
|
|
181
222
|
return profile;
|
|
182
223
|
}
|
|
183
224
|
/**
|
|
@@ -13,7 +13,8 @@ export declare class ProviderRegistry {
|
|
|
13
13
|
private staticProviders;
|
|
14
14
|
private providerInstances;
|
|
15
15
|
private providerLoader?;
|
|
16
|
-
|
|
16
|
+
private ctx;
|
|
17
|
+
constructor(staticProviders: Record<string, OidcProviderConfig>, providerLoader?: (providerName: string, ctx: any) => Promise<OidcProviderConfig | null>, ctx?: any);
|
|
17
18
|
/**
|
|
18
19
|
* Get provider instance by name
|
|
19
20
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ProviderRegistry.d.ts","sourceRoot":"","sources":["../../src/providers/ProviderRegistry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;
|
|
1
|
+
{"version":3,"file":"ProviderRegistry.d.ts","sourceRoot":"","sources":["../../src/providers/ProviderRegistry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAI3D;;;;;;;;GAQG;AACH,qBAAa,gBAAgB;IACzB,OAAO,CAAC,eAAe,CAAqC;IAC5D,OAAO,CAAC,iBAAiB,CAAwC;IACjE,OAAO,CAAC,cAAc,CAAC,CAAyE;IAChG,OAAO,CAAC,GAAG,CAAM;gBAGb,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,EACnD,cAAc,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,EACvF,GAAG,CAAC,EAAE,GAAG;IAOb;;;;;;;;;;;;;OAaG;IACG,WAAW,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;IA+C9D;;;;;OAKG;IACH,WAAW,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO;IAI1C;;;;;;;OAOG;IACH,UAAU,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI;IAQvC;;;;OAIG;IACH,gBAAgB,IAAI,MAAM,EAAE;CAG/B"}
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.ProviderRegistry = void 0;
|
|
4
4
|
const OidcProvider_1 = require("./OidcProvider");
|
|
5
5
|
const error_utils_1 = require("../utils/error-utils");
|
|
6
|
+
const log_1 = require("../log");
|
|
6
7
|
/**
|
|
7
8
|
* Provider registry for managing OIDC provider instances
|
|
8
9
|
*
|
|
@@ -13,10 +14,11 @@ const error_utils_1 = require("../utils/error-utils");
|
|
|
13
14
|
* - Lazy initialization
|
|
14
15
|
*/
|
|
15
16
|
class ProviderRegistry {
|
|
16
|
-
constructor(staticProviders, providerLoader) {
|
|
17
|
+
constructor(staticProviders, providerLoader, ctx) {
|
|
17
18
|
this.providerInstances = new Map();
|
|
18
19
|
this.staticProviders = staticProviders;
|
|
19
20
|
this.providerLoader = providerLoader;
|
|
21
|
+
this.ctx = ctx;
|
|
20
22
|
}
|
|
21
23
|
/**
|
|
22
24
|
* Get provider instance by name
|
|
@@ -36,20 +38,26 @@ class ProviderRegistry {
|
|
|
36
38
|
// Check cache first
|
|
37
39
|
const cachedProvider = this.providerInstances.get(providerName);
|
|
38
40
|
if (cachedProvider) {
|
|
41
|
+
log_1.oidcLog.debug(`Provider "${providerName}": using cached instance`);
|
|
39
42
|
return cachedProvider;
|
|
40
43
|
}
|
|
41
44
|
// Try static configuration
|
|
42
45
|
let config = this.staticProviders[providerName] || null;
|
|
46
|
+
let configSource = "static";
|
|
43
47
|
// Try dynamic loader if not in static config
|
|
44
48
|
if (!config && this.providerLoader) {
|
|
45
|
-
config
|
|
49
|
+
log_1.oidcLog.debug(`Provider "${providerName}": not in static config, invoking providerLoader`);
|
|
50
|
+
config = await this.providerLoader(providerName, this.ctx);
|
|
51
|
+
configSource = "dynamic";
|
|
46
52
|
}
|
|
47
53
|
if (!config) {
|
|
54
|
+
log_1.oidcLog.debug(`Provider "${providerName}": not found (static=[${Object.keys(this.staticProviders).join(", ")}], loader=${!!this.providerLoader})`);
|
|
48
55
|
throw (0, error_utils_1.createOidcError)(error_utils_1.OidcErrorCodes.PROVIDER_NOT_CONFIGURED, `OIDC provider '${providerName}' is not configured`, {
|
|
49
56
|
providerName,
|
|
50
57
|
availableProviders: Object.keys(this.staticProviders),
|
|
51
58
|
});
|
|
52
59
|
}
|
|
60
|
+
log_1.oidcLog.debug(`Provider "${providerName}" resolved via ${configSource}:`, `issuer=${config.issuer}`, `clientId=${config.clientId}`, `clientSecret=${(0, log_1.maskSecret)(config.clientSecret)}`, `callbackUrl=${config.callbackUrl}`, config.discoveryUrl ? `discoveryUrl=${config.discoveryUrl}` : `authorizationEndpoint=${config.authorizationEndpoint}`, `scope=${(config.scope || ["openid", "email", "profile"]).join(" ")}`);
|
|
53
61
|
// Create and initialize provider
|
|
54
62
|
const provider = new OidcProvider_1.OidcProvider(config);
|
|
55
63
|
await provider.initialize();
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* GET /oidc/:provider/callback?code=...&state=...&response_type=json
|
|
5
5
|
*/
|
|
6
|
-
|
|
6
|
+
type CallbackRequest = Record<string, string | string[]> & {
|
|
7
7
|
/**
|
|
8
8
|
* Authorization code from the IdP
|
|
9
9
|
* Required for successful authentication
|
|
@@ -29,9 +29,6 @@ export default interface CallbackRequest {
|
|
|
29
29
|
* - undefined: Redirect to redirectUri with token in URL fragment
|
|
30
30
|
*/
|
|
31
31
|
response_type?: "json";
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
*/
|
|
35
|
-
[key: string]: string | string[] | undefined;
|
|
36
|
-
}
|
|
32
|
+
};
|
|
33
|
+
export default CallbackRequest;
|
|
37
34
|
//# sourceMappingURL=CallbackRequest.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CallbackRequest.d.ts","sourceRoot":"","sources":["../../src/schemas/CallbackRequest.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,CAAC,
|
|
1
|
+
{"version":3,"file":"CallbackRequest.d.ts","sourceRoot":"","sources":["../../src/schemas/CallbackRequest.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,KAAK,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,GAAG;IACvD;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IAEd;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;OAEG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAE3B;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CAE1B,CAAC;AAEF,eAAe,eAAe,CAAC"}
|
|
@@ -3,15 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* GET /oidc/:provider/initiate?redirectUri=...
|
|
5
5
|
*/
|
|
6
|
-
|
|
6
|
+
type InitiateRequest = Record<string, string | string[]> & {
|
|
7
7
|
/**
|
|
8
8
|
* Optional redirect URI after successful authentication
|
|
9
9
|
* If not provided, uses the default callbackUrl from provider config
|
|
10
10
|
*/
|
|
11
11
|
redirectUri?: string;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
*/
|
|
15
|
-
[key: string]: string | string[] | undefined;
|
|
16
|
-
}
|
|
12
|
+
};
|
|
13
|
+
export default InitiateRequest;
|
|
17
14
|
//# sourceMappingURL=InitiateRequest.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"InitiateRequest.d.ts","sourceRoot":"","sources":["../../src/schemas/InitiateRequest.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,CAAC,
|
|
1
|
+
{"version":3,"file":"InitiateRequest.d.ts","sourceRoot":"","sources":["../../src/schemas/InitiateRequest.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,KAAK,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,GAAG;IACvD;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,eAAe,eAAe,CAAC"}
|
|
@@ -37,6 +37,11 @@ export default interface OidcSession {
|
|
|
37
37
|
* Can be overridden by the client via query parameter
|
|
38
38
|
*/
|
|
39
39
|
redirectUri: string;
|
|
40
|
+
/**
|
|
41
|
+
* Arbitrary metadata collected from meta.* query parameters on initiate
|
|
42
|
+
* Passed through to onAuthSuccess and onAuthError callbacks unchanged
|
|
43
|
+
*/
|
|
44
|
+
metadata?: Record<string, string>;
|
|
40
45
|
/**
|
|
41
46
|
* Session creation timestamp
|
|
42
47
|
* MongoDB TTL index will automatically delete expired sessions
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"OidcSession.d.ts","sourceRoot":"","sources":["../../src/schemas/OidcSession.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,OAAO,WAAW,WAAW;IAChC;;OAEG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IAEb;;OAEG;IACH,SAAS,EAAE,MAAM,CAAC;IAElB;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;;OAGG;IACH,YAAY,EAAE,MAAM,CAAC;IAErB;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;OAEG;IACH,QAAQ,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,SAAS,EAAE,IAAI,CAAC;CACnB"}
|
|
1
|
+
{"version":3,"file":"OidcSession.d.ts","sourceRoot":"","sources":["../../src/schemas/OidcSession.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,OAAO,WAAW,WAAW;IAChC;;OAEG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IAEb;;OAEG;IACH,SAAS,EAAE,MAAM,CAAC;IAElB;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;;OAGG;IACH,YAAY,EAAE,MAAM,CAAC;IAErB;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;OAEG;IACH,QAAQ,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,WAAW,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAElC;;;OAGG;IACH,SAAS,EAAE,IAAI,CAAC;CACnB"}
|
|
@@ -1,18 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Controls how the JWT token is delivered back to the client on the
|
|
3
|
+
* post-callback redirect.
|
|
4
|
+
*
|
|
5
|
+
* - "fragment": appends `#token=...` to the redirectUrl. Best for web
|
|
6
|
+
* browsers — URL fragments are not sent to the server in HTTP requests
|
|
7
|
+
* and are only accessible to client-side JavaScript.
|
|
8
|
+
* - "query": appends `?token=...` (or `&token=...`). Required for native
|
|
9
|
+
* mobile flows where the callback comes back via a custom URL scheme,
|
|
10
|
+
* because iOS `ASWebAuthenticationSession` strips URL fragments before
|
|
11
|
+
* delivering the URL to the app.
|
|
12
|
+
*/
|
|
13
|
+
export type TokenDeliveryMode = "fragment" | "query";
|
|
1
14
|
/**
|
|
2
15
|
* Format token response for the client
|
|
3
16
|
*
|
|
4
17
|
* Supports two response formats:
|
|
5
18
|
* 1. JSON response (for API clients)
|
|
6
|
-
* 2. Redirect with token in URL fragment (for web
|
|
7
|
-
*
|
|
8
|
-
* URL fragments are used for security - they are NOT sent to the server
|
|
9
|
-
* in HTTP requests and are only accessible to client-side JavaScript.
|
|
19
|
+
* 2. Redirect with token in URL fragment or query (for web/native clients)
|
|
10
20
|
*
|
|
11
21
|
* @param token - JWT token for the application
|
|
12
22
|
* @param user - User object
|
|
13
23
|
* @param redirectUrl - URL to redirect to
|
|
14
24
|
* @param responseType - "json" or undefined (redirect)
|
|
25
|
+
* @param tokenDelivery - How to attach the token on redirect. Defaults to "fragment".
|
|
15
26
|
* @returns Flink response object
|
|
16
27
|
*/
|
|
17
|
-
export declare function formatTokenResponse(token: string, user: any, redirectUrl: string, responseType?: "json"): any;
|
|
28
|
+
export declare function formatTokenResponse(token: string, user: any, redirectUrl: string, responseType?: "json", tokenDelivery?: TokenDeliveryMode): any;
|
|
18
29
|
//# sourceMappingURL=response-utils.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"response-utils.d.ts","sourceRoot":"","sources":["../../src/utils/response-utils.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"response-utils.d.ts","sourceRoot":"","sources":["../../src/utils/response-utils.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,iBAAiB,GAAG,UAAU,GAAG,OAAO,CAAC;AAErD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CAC/B,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,GAAG,EACT,WAAW,EAAE,MAAM,EACnB,YAAY,CAAC,EAAE,MAAM,EACrB,aAAa,GAAE,iBAA8B,GAC9C,GAAG,CAqBL"}
|
|
@@ -6,18 +6,16 @@ exports.formatTokenResponse = formatTokenResponse;
|
|
|
6
6
|
*
|
|
7
7
|
* Supports two response formats:
|
|
8
8
|
* 1. JSON response (for API clients)
|
|
9
|
-
* 2. Redirect with token in URL fragment (for web
|
|
10
|
-
*
|
|
11
|
-
* URL fragments are used for security - they are NOT sent to the server
|
|
12
|
-
* in HTTP requests and are only accessible to client-side JavaScript.
|
|
9
|
+
* 2. Redirect with token in URL fragment or query (for web/native clients)
|
|
13
10
|
*
|
|
14
11
|
* @param token - JWT token for the application
|
|
15
12
|
* @param user - User object
|
|
16
13
|
* @param redirectUrl - URL to redirect to
|
|
17
14
|
* @param responseType - "json" or undefined (redirect)
|
|
15
|
+
* @param tokenDelivery - How to attach the token on redirect. Defaults to "fragment".
|
|
18
16
|
* @returns Flink response object
|
|
19
17
|
*/
|
|
20
|
-
function formatTokenResponse(token, user, redirectUrl, responseType) {
|
|
18
|
+
function formatTokenResponse(token, user, redirectUrl, responseType, tokenDelivery = "fragment") {
|
|
21
19
|
// JSON response for API clients
|
|
22
20
|
if (responseType === "json") {
|
|
23
21
|
return {
|
|
@@ -27,11 +25,8 @@ function formatTokenResponse(token, user, redirectUrl, responseType) {
|
|
|
27
25
|
},
|
|
28
26
|
};
|
|
29
27
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const separator = redirectUrl.includes("#") ? "&" : "#";
|
|
33
|
-
const tokenFragment = `token=${encodeURIComponent(token)}`;
|
|
34
|
-
const finalUrl = `${redirectUrl}${separator}${tokenFragment}`;
|
|
28
|
+
const tokenParam = `token=${encodeURIComponent(token)}`;
|
|
29
|
+
const finalUrl = appendToken(redirectUrl, tokenParam, tokenDelivery);
|
|
35
30
|
return {
|
|
36
31
|
status: 302,
|
|
37
32
|
headers: {
|
|
@@ -40,3 +35,29 @@ function formatTokenResponse(token, user, redirectUrl, responseType) {
|
|
|
40
35
|
data: {},
|
|
41
36
|
};
|
|
42
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Append a token parameter to a URL using the specified delivery mode.
|
|
40
|
+
*
|
|
41
|
+
* For "query" mode, any existing `#fragment` on the URL is preserved: the
|
|
42
|
+
* query parameter is spliced in *before* the fragment so the final URL is
|
|
43
|
+
* still well-formed.
|
|
44
|
+
*
|
|
45
|
+
* For "fragment" mode, a second token on an existing fragment is joined
|
|
46
|
+
* with `&` (same as the old behavior).
|
|
47
|
+
*/
|
|
48
|
+
function appendToken(redirectUrl, tokenParam, mode) {
|
|
49
|
+
if (mode === "query") {
|
|
50
|
+
const hashIdx = redirectUrl.indexOf("#");
|
|
51
|
+
if (hashIdx >= 0) {
|
|
52
|
+
const base = redirectUrl.slice(0, hashIdx);
|
|
53
|
+
const frag = redirectUrl.slice(hashIdx);
|
|
54
|
+
const sep = base.includes("?") ? "&" : "?";
|
|
55
|
+
return `${base}${sep}${tokenParam}${frag}`;
|
|
56
|
+
}
|
|
57
|
+
const sep = redirectUrl.includes("?") ? "&" : "?";
|
|
58
|
+
return `${redirectUrl}${sep}${tokenParam}`;
|
|
59
|
+
}
|
|
60
|
+
// fragment (default)
|
|
61
|
+
const sep = redirectUrl.includes("#") ? "&" : "#";
|
|
62
|
+
return `${redirectUrl}${sep}${tokenParam}`;
|
|
63
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flink-app/oidc-plugin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0-alpha.101",
|
|
4
4
|
"description": "Flink plugin for OIDC authentication with generic IdP support",
|
|
5
5
|
"author": "joel@frost.se",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,19 +10,16 @@
|
|
|
10
10
|
"access": "public"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"openid-client": "^5.7.0"
|
|
13
|
+
"openid-client": "^5.7.0",
|
|
14
|
+
"@flink-app/jwt-auth-plugin": "2.0.0-alpha.101"
|
|
14
15
|
},
|
|
15
16
|
"peerDependencies": {
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"@flink-app/jwt-auth-plugin": "0.13.0"
|
|
17
|
+
"@flink-app/flink": ">=2.0.0-alpha.101",
|
|
18
|
+
"mongodb": "^6.15.0"
|
|
19
19
|
},
|
|
20
20
|
"peerDependenciesMeta": {
|
|
21
21
|
"@flink-app/flink": {
|
|
22
22
|
"optional": false
|
|
23
|
-
},
|
|
24
|
-
"@flink-app/jwt-auth-plugin": {
|
|
25
|
-
"optional": false
|
|
26
23
|
}
|
|
27
24
|
},
|
|
28
25
|
"devDependencies": {
|
|
@@ -30,9 +27,9 @@
|
|
|
30
27
|
"@types/node": "22.13.10",
|
|
31
28
|
"ts-node": "^10.9.2",
|
|
32
29
|
"tsc-watch": "^4.2.9",
|
|
33
|
-
"@flink-app/
|
|
34
|
-
"@flink-app/
|
|
35
|
-
"@flink-app/
|
|
30
|
+
"@flink-app/test-utils": "2.0.0-alpha.101",
|
|
31
|
+
"@flink-app/flink": "2.0.0-alpha.101",
|
|
32
|
+
"@flink-app/jwt-auth-plugin": "2.0.0-alpha.101"
|
|
36
33
|
},
|
|
37
34
|
"scripts": {
|
|
38
35
|
"test": "jasmine-ts --config=./spec/support/jasmine.json",
|
|
@@ -39,6 +39,20 @@ export function createTestProviderConfig(overrides?: Partial<OidcProviderConfig>
|
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Create a test OIDC provider configuration for a public client (no clientSecret)
|
|
44
|
+
*/
|
|
45
|
+
export function createPublicClientProviderConfig(overrides?: Partial<OidcProviderConfig>): OidcProviderConfig {
|
|
46
|
+
return {
|
|
47
|
+
issuer: "https://test-idp.example.com",
|
|
48
|
+
clientId: "test-public-client-id",
|
|
49
|
+
callbackUrl: "http://localhost:3000/oidc/test/callback",
|
|
50
|
+
discoveryUrl: "https://test-idp.example.com/.well-known/openid-configuration",
|
|
51
|
+
scope: ["openid", "email", "profile"],
|
|
52
|
+
...overrides,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
/**
|
|
43
57
|
* Create a mock OIDC token set
|
|
44
58
|
*/
|
|
@@ -6,18 +6,45 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { oidcPlugin } from "../../src/OidcPlugin";
|
|
9
|
-
import { createTestProviderConfig } from "../helpers/test-helpers";
|
|
9
|
+
import { createTestProviderConfig, createPublicClientProviderConfig } from "../helpers/test-helpers";
|
|
10
10
|
|
|
11
11
|
describe("OidcPlugin", () => {
|
|
12
12
|
|
|
13
13
|
describe("configuration validation", () => {
|
|
14
|
-
it("should throw error if no providers configured", () => {
|
|
14
|
+
it("should throw error if no providers and no providerLoader configured", () => {
|
|
15
15
|
expect(() => {
|
|
16
16
|
oidcPlugin({
|
|
17
17
|
providers: {},
|
|
18
18
|
onAuthSuccess: async () => ({ user: {}, token: "", redirectUrl: "" }),
|
|
19
19
|
});
|
|
20
|
-
}).toThrowError(/At least one provider must be configured/);
|
|
20
|
+
}).toThrowError(/At least one provider must be configured, or providerLoader must be provided/);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should throw error if providers omitted and no providerLoader configured", () => {
|
|
24
|
+
expect(() => {
|
|
25
|
+
oidcPlugin({
|
|
26
|
+
onAuthSuccess: async () => ({ user: {}, token: "", redirectUrl: "" }),
|
|
27
|
+
});
|
|
28
|
+
}).toThrowError(/At least one provider must be configured, or providerLoader must be provided/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should accept empty providers when providerLoader is configured", () => {
|
|
32
|
+
expect(() => {
|
|
33
|
+
oidcPlugin({
|
|
34
|
+
providers: {},
|
|
35
|
+
providerLoader: async () => null,
|
|
36
|
+
onAuthSuccess: async () => ({ user: {}, token: "", redirectUrl: "" }),
|
|
37
|
+
});
|
|
38
|
+
}).not.toThrow();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should accept omitted providers when providerLoader is configured", () => {
|
|
42
|
+
expect(() => {
|
|
43
|
+
oidcPlugin({
|
|
44
|
+
providerLoader: async () => null,
|
|
45
|
+
onAuthSuccess: async () => ({ user: {}, token: "", redirectUrl: "" }),
|
|
46
|
+
});
|
|
47
|
+
}).not.toThrow();
|
|
21
48
|
});
|
|
22
49
|
|
|
23
50
|
it("should throw error if onAuthSuccess is missing", () => {
|
|
@@ -72,6 +99,80 @@ describe("OidcPlugin", () => {
|
|
|
72
99
|
});
|
|
73
100
|
});
|
|
74
101
|
|
|
102
|
+
describe("public client (PKCE-only) support", () => {
|
|
103
|
+
it("should accept provider without clientSecret when encryptionKey is provided", () => {
|
|
104
|
+
expect(() => {
|
|
105
|
+
oidcPlugin({
|
|
106
|
+
providers: {
|
|
107
|
+
test: createPublicClientProviderConfig(),
|
|
108
|
+
},
|
|
109
|
+
encryptionKey: "valid-encryption-key-at-least-32-chars-long",
|
|
110
|
+
onAuthSuccess: async () => ({ user: {}, token: "", redirectUrl: "" }),
|
|
111
|
+
});
|
|
112
|
+
}).not.toThrow();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should accept provider without clientSecret when storeTokens is false", () => {
|
|
116
|
+
expect(() => {
|
|
117
|
+
oidcPlugin({
|
|
118
|
+
providers: {
|
|
119
|
+
test: createPublicClientProviderConfig(),
|
|
120
|
+
},
|
|
121
|
+
storeTokens: false,
|
|
122
|
+
onAuthSuccess: async () => ({ user: {}, token: "", redirectUrl: "" }),
|
|
123
|
+
});
|
|
124
|
+
}).not.toThrow();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("should accept provider without clientSecret when storeTokens is not set (defaults to false)", () => {
|
|
128
|
+
expect(() => {
|
|
129
|
+
oidcPlugin({
|
|
130
|
+
providers: {
|
|
131
|
+
test: createPublicClientProviderConfig(),
|
|
132
|
+
},
|
|
133
|
+
onAuthSuccess: async () => ({ user: {}, token: "", redirectUrl: "" }),
|
|
134
|
+
});
|
|
135
|
+
}).not.toThrow();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("should throw when storeTokens is true, no encryptionKey, and no provider has clientSecret", () => {
|
|
139
|
+
expect(() => {
|
|
140
|
+
oidcPlugin({
|
|
141
|
+
providers: {
|
|
142
|
+
test: createPublicClientProviderConfig(),
|
|
143
|
+
},
|
|
144
|
+
storeTokens: true,
|
|
145
|
+
onAuthSuccess: async () => ({ user: {}, token: "", redirectUrl: "" }),
|
|
146
|
+
});
|
|
147
|
+
}).toThrowError(/Encryption key must be at least 32 characters/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("should derive encryption key from provider with clientSecret when mixed with public clients", () => {
|
|
151
|
+
expect(() => {
|
|
152
|
+
oidcPlugin({
|
|
153
|
+
providers: {
|
|
154
|
+
publicProvider: createPublicClientProviderConfig(),
|
|
155
|
+
confidentialProvider: createTestProviderConfig(),
|
|
156
|
+
},
|
|
157
|
+
onAuthSuccess: async () => ({ user: {}, token: "", redirectUrl: "" }),
|
|
158
|
+
});
|
|
159
|
+
}).not.toThrow();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("should accept storeTokens with explicit encryptionKey for public clients", () => {
|
|
163
|
+
expect(() => {
|
|
164
|
+
oidcPlugin({
|
|
165
|
+
providers: {
|
|
166
|
+
test: createPublicClientProviderConfig(),
|
|
167
|
+
},
|
|
168
|
+
storeTokens: true,
|
|
169
|
+
encryptionKey: "valid-encryption-key-at-least-32-chars-long",
|
|
170
|
+
onAuthSuccess: async () => ({ user: {}, token: "", redirectUrl: "" }),
|
|
171
|
+
});
|
|
172
|
+
}).not.toThrow();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
75
176
|
describe("plugin structure", () => {
|
|
76
177
|
it("should return plugin with correct ID", () => {
|
|
77
178
|
const plugin = oidcPlugin({
|
|
@@ -29,7 +29,7 @@ describe("ProviderRegistry", () => {
|
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
it("should create registry with provider loader", () => {
|
|
32
|
-
const loader = async (name: string) => null;
|
|
32
|
+
const loader = async (name: string, ctx: any) => null;
|
|
33
33
|
const registry = new ProviderRegistry(staticProviders, loader);
|
|
34
34
|
expect(registry).toBeDefined();
|
|
35
35
|
});
|
|
@@ -49,7 +49,7 @@ describe("ProviderRegistry", () => {
|
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
it("should return true if provider loader is configured", () => {
|
|
52
|
-
const loader = async (name: string) => null;
|
|
52
|
+
const loader = async (name: string, ctx: any) => null;
|
|
53
53
|
const registry = new ProviderRegistry(staticProviders, loader);
|
|
54
54
|
// hasProvider returns true if loader exists, even for unknown providers
|
|
55
55
|
expect(registry.hasProvider("unknown")).toBe(true);
|
|
@@ -87,7 +87,8 @@ describe("ProviderRegistry", () => {
|
|
|
87
87
|
});
|
|
88
88
|
|
|
89
89
|
const loader = jasmine.createSpy("loader").and.returnValue(Promise.resolve(dynamicConfig));
|
|
90
|
-
const
|
|
90
|
+
const mockCtx = { repos: {} };
|
|
91
|
+
const registry = new ProviderRegistry(staticProviders, loader, mockCtx);
|
|
91
92
|
|
|
92
93
|
// Note: This will attempt real OIDC discovery, which will fail in tests
|
|
93
94
|
// We're primarily testing that the loader is called
|
|
@@ -95,13 +96,13 @@ describe("ProviderRegistry", () => {
|
|
|
95
96
|
await registry.getProvider("okta");
|
|
96
97
|
} catch (error) {
|
|
97
98
|
// Discovery will fail, but loader should have been called
|
|
98
|
-
expect(loader).toHaveBeenCalledWith("okta");
|
|
99
|
+
expect(loader).toHaveBeenCalledWith("okta", mockCtx);
|
|
99
100
|
}
|
|
100
101
|
});
|
|
101
102
|
|
|
102
103
|
it("should prioritize static config over loader", async () => {
|
|
103
104
|
const loader = jasmine.createSpy("loader").and.returnValue(Promise.resolve(null));
|
|
104
|
-
const registry = new ProviderRegistry(staticProviders, loader);
|
|
105
|
+
const registry = new ProviderRegistry(staticProviders, loader, {});
|
|
105
106
|
|
|
106
107
|
// Note: This will attempt real OIDC discovery
|
|
107
108
|
try {
|
|
@@ -113,7 +114,7 @@ describe("ProviderRegistry", () => {
|
|
|
113
114
|
});
|
|
114
115
|
|
|
115
116
|
it("should throw error if loader returns null", async () => {
|
|
116
|
-
const loader = async (name: string) => null;
|
|
117
|
+
const loader = async (name: string, ctx: any) => null;
|
|
117
118
|
const registry = new ProviderRegistry({}, loader);
|
|
118
119
|
|
|
119
120
|
try {
|
|
@@ -169,7 +170,7 @@ describe("ProviderRegistry", () => {
|
|
|
169
170
|
})
|
|
170
171
|
)
|
|
171
172
|
);
|
|
172
|
-
const registry = new ProviderRegistry({}, loader);
|
|
173
|
+
const registry = new ProviderRegistry({}, loader, {});
|
|
173
174
|
|
|
174
175
|
// First call - will attempt to initialize (and fail on discovery)
|
|
175
176
|
try {
|