@flink-app/oidc-plugin 0.14.3 → 2.0.0-alpha.100

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +485 -0
  2. package/README.md +23 -2
  3. package/dist/OidcPlugin.d.ts +1 -1
  4. package/dist/OidcPlugin.d.ts.map +1 -1
  5. package/dist/OidcPlugin.js +32 -18
  6. package/dist/OidcPluginOptions.d.ts +75 -8
  7. package/dist/OidcPluginOptions.d.ts.map +1 -1
  8. package/dist/OidcProviderConfig.d.ts +21 -1
  9. package/dist/OidcProviderConfig.d.ts.map +1 -1
  10. package/dist/handlers/CallbackOidc.d.ts.map +1 -1
  11. package/dist/handlers/CallbackOidc.js +50 -5
  12. package/dist/handlers/InitiateOidc.d.ts.map +1 -1
  13. package/dist/handlers/InitiateOidc.js +14 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/log.d.ts +27 -0
  17. package/dist/log.d.ts.map +1 -0
  18. package/dist/log.js +37 -0
  19. package/dist/providers/OidcProvider.d.ts +1 -1
  20. package/dist/providers/OidcProvider.d.ts.map +1 -1
  21. package/dist/providers/OidcProvider.js +46 -5
  22. package/dist/providers/ProviderRegistry.d.ts +2 -1
  23. package/dist/providers/ProviderRegistry.d.ts.map +1 -1
  24. package/dist/providers/ProviderRegistry.js +10 -2
  25. package/dist/schemas/CallbackRequest.d.ts +3 -6
  26. package/dist/schemas/CallbackRequest.d.ts.map +1 -1
  27. package/dist/schemas/InitiateRequest.d.ts +3 -6
  28. package/dist/schemas/InitiateRequest.d.ts.map +1 -1
  29. package/dist/schemas/OidcSession.d.ts +5 -0
  30. package/dist/schemas/OidcSession.d.ts.map +1 -1
  31. package/dist/utils/response-utils.d.ts +16 -5
  32. package/dist/utils/response-utils.d.ts.map +1 -1
  33. package/dist/utils/response-utils.js +31 -10
  34. package/examples/multi-provider.ts +1 -1
  35. package/package.json +8 -11
  36. package/spec/helpers/test-helpers.ts +14 -0
  37. package/spec/plugin/OidcPlugin.spec.ts +104 -3
  38. package/spec/providers/ProviderRegistry.spec.ts +8 -7
  39. package/spec/utils/response-utils.spec.ts +85 -0
  40. package/src/OidcPlugin.ts +54 -21
  41. package/src/OidcPluginOptions.ts +84 -9
  42. package/src/OidcProviderConfig.ts +23 -1
  43. package/src/handlers/CallbackOidc.ts +66 -5
  44. package/src/handlers/InitiateOidc.ts +19 -1
  45. package/src/index.ts +1 -1
  46. package/src/log.ts +33 -0
  47. package/src/providers/OidcProvider.ts +60 -7
  48. package/src/providers/ProviderRegistry.ts +22 -3
  49. package/src/schemas/CallbackRequest.ts +4 -6
  50. package/src/schemas/InitiateRequest.ts +3 -6
  51. package/src/schemas/OidcSession.ts +6 -0
  52. package/src/utils/response-utils.ts +53 -10
@@ -16,6 +16,7 @@ import InitiateRequest from "../schemas/InitiateRequest";
16
16
  import { generateState, generateSessionId, generateNonce } from "../utils/state-utils";
17
17
  import { validateProvider, createOidcError, OidcErrorCodes } from "../utils/error-utils";
18
18
  import { generators } from "openid-client";
19
+ import { oidcLog } from "../log";
19
20
 
20
21
  /**
21
22
  * Path parameters for the handler
@@ -48,6 +49,8 @@ const InitiateOidc: GetHandler<any, any, PathParams, InitiateRequest> = async ({
48
49
  // Validate provider name format
49
50
  validateProvider(provider);
50
51
 
52
+ oidcLog.debug(`Initiate: provider="${provider}" redirectUri="${redirectUri || "(not set)"}"`);
53
+
51
54
  // Get provider registry from context
52
55
  const providerRegistry = (ctx as any).oidcProviderRegistry;
53
56
  if (!providerRegistry) {
@@ -61,9 +64,19 @@ const InitiateOidc: GetHandler<any, any, PathParams, InitiateRequest> = async ({
61
64
  const { options } = ctx.plugins.oidc;
62
65
 
63
66
  // Determine redirect URI (use provided or default to callback URL)
64
- const staticProviderConfig = options.providers[provider];
67
+ const staticProviderConfig = options.providers?.[provider];
65
68
  const finalRedirectUri = redirectUri || staticProviderConfig?.callbackUrl || `${req.protocol}://${req.get("host")}/oidc/${provider}/callback`;
66
69
 
70
+ // Collect meta.* query params as opaque metadata (prefix stripped)
71
+ const metadata: Record<string, string> = {};
72
+ for (const [key, value] of Object.entries(req.query)) {
73
+ if (key.startsWith("meta.") && typeof value === "string") {
74
+ metadata[key.slice(5)] = value;
75
+ }
76
+ }
77
+
78
+ oidcLog.debug(`Initiate: resolved redirectUri="${finalRedirectUri}" (source: ${redirectUri ? "query param" : staticProviderConfig?.callbackUrl ? "provider config" : "auto-detected"})`);
79
+
67
80
  // Generate cryptographically secure parameters
68
81
  const state = generateState();
69
82
  const sessionId = generateSessionId();
@@ -78,9 +91,12 @@ const InitiateOidc: GetHandler<any, any, PathParams, InitiateRequest> = async ({
78
91
  nonce,
79
92
  provider,
80
93
  redirectUri: finalRedirectUri,
94
+ metadata,
81
95
  createdAt: new Date(),
82
96
  });
83
97
 
98
+ oidcLog.debug(`Initiate: session created sessionId=${sessionId}`);
99
+
84
100
  // Build authorization URL with PKCE and nonce
85
101
  const authorizationUrl = await oidcProvider.getAuthorizationUrl({
86
102
  state,
@@ -88,6 +104,8 @@ const InitiateOidc: GetHandler<any, any, PathParams, InitiateRequest> = async ({
88
104
  nonce,
89
105
  });
90
106
 
107
+ oidcLog.debug(`Initiate: redirecting to IdP authorization URL: ${authorizationUrl}`);
108
+
91
109
  // Redirect user to provider's authorization page
92
110
  return {
93
111
  status: 302,
package/src/index.ts CHANGED
@@ -34,5 +34,5 @@ export { ProviderRegistry } from "./providers/ProviderRegistry";
34
34
  export { generateState, generateSessionId, generateNonce, validateState } from "./utils/state-utils";
35
35
  export { encryptToken, decryptToken, validateEncryptionSecret } from "./utils/encryption-utils";
36
36
  export { mapClaimsToProfile, extractCustomClaims } from "./utils/claims-mapper";
37
- export { formatTokenResponse } from "./utils/response-utils";
37
+ export { formatTokenResponse, TokenDeliveryMode } from "./utils/response-utils";
38
38
  export { createOidcError, validateProvider, handleProviderError, OidcErrorCodes } from "./utils/error-utils";
package/src/log.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { FlinkLogFactory } from "@flink-app/flink";
2
+
3
+ /**
4
+ * Named logger for the OIDC plugin.
5
+ *
6
+ * Enables independent log level control via flink.config.js:
7
+ * ```js
8
+ * module.exports = {
9
+ * logging: {
10
+ * components: {
11
+ * "flink.oidc": "debug"
12
+ * }
13
+ * }
14
+ * };
15
+ * ```
16
+ *
17
+ * Or at runtime:
18
+ * ```ts
19
+ * FlinkLogFactory.setComponentLevel("flink.oidc", "debug");
20
+ * ```
21
+ */
22
+ export const oidcLog = FlinkLogFactory.createLogger("flink.oidc");
23
+
24
+ /**
25
+ * Mask a secret value for safe logging.
26
+ * Shows the first 4 characters followed by **** to help identify which secret
27
+ * is in use without exposing the full value.
28
+ */
29
+ export function maskSecret(value: string | undefined): string {
30
+ if (!value) return "(not set)";
31
+ if (value.length <= 4) return "****";
32
+ return `${value.slice(0, 4)}****`;
33
+ }
@@ -1,9 +1,10 @@
1
- import { Issuer, Client, generators, TokenSet, UserinfoResponse } from "openid-client";
1
+ import { Issuer, Client, ClientMetadata, generators, TokenSet, UserinfoResponse } from "openid-client";
2
2
  import { OidcProviderConfig } from "../OidcProviderConfig";
3
3
  import OidcProfile from "../schemas/OidcProfile";
4
4
  import OidcTokenSet from "../schemas/OidcTokenSet";
5
5
  import { mapClaimsToProfile, extractCustomClaims } from "../utils/claims-mapper";
6
6
  import { createOidcError, OidcErrorCodes } from "../utils/error-utils";
7
+ import { oidcLog } from "../log";
7
8
 
8
9
  /**
9
10
  * Generic OIDC Provider implementation using openid-client
@@ -17,7 +18,7 @@ import { createOidcError, OidcErrorCodes } from "../utils/error-utils";
17
18
  * - Claims mapping to profile
18
19
  */
19
20
  export class OidcProvider {
20
- private config: OidcProviderConfig;
21
+ public readonly config: OidcProviderConfig;
21
22
  private issuer: Issuer<Client> | null = null;
22
23
  private client: Client | null = null;
23
24
  private initialized: boolean = false;
@@ -42,7 +43,19 @@ export class OidcProvider {
42
43
  try {
43
44
  // Option 1: OIDC Discovery
44
45
  if (this.config.discoveryUrl) {
46
+ oidcLog.debug(`Provider "${this.config.issuer}": discovering from ${this.config.discoveryUrl}`);
45
47
  this.issuer = await Issuer.discover(this.config.discoveryUrl);
48
+ oidcLog.debug(
49
+ `Provider "${this.config.issuer}": discovery complete`,
50
+ `authorization_endpoint=${this.issuer.metadata.authorization_endpoint}`,
51
+ `token_endpoint=${this.issuer.metadata.token_endpoint}`,
52
+ `userinfo_endpoint=${this.issuer.metadata.userinfo_endpoint ?? "(none)"}`,
53
+ `jwks_uri=${this.issuer.metadata.jwks_uri}`
54
+ );
55
+ oidcLog.trace(
56
+ `Provider "${this.config.issuer}": raw discovery metadata from ${this.config.discoveryUrl}`,
57
+ this.issuer.metadata
58
+ );
46
59
  }
47
60
  // Option 2: Manual configuration
48
61
  else {
@@ -65,12 +78,23 @@ export class OidcProvider {
65
78
  }
66
79
 
67
80
  // Create OIDC client
68
- this.client = new this.issuer.Client({
81
+ const clientMetadata: ClientMetadata = {
69
82
  client_id: this.config.clientId,
70
- client_secret: this.config.clientSecret,
71
83
  redirect_uris: [this.config.callbackUrl],
72
84
  response_types: ["code"],
73
- });
85
+ };
86
+
87
+ if (this.config.clientSecret) {
88
+ clientMetadata.client_secret = this.config.clientSecret;
89
+ clientMetadata.token_endpoint_auth_method =
90
+ this.config.tokenEndpointAuthMethod || "client_secret_basic";
91
+ } else {
92
+ // Public client (PKCE-only) — openid-client requires this to skip client authentication
93
+ clientMetadata.token_endpoint_auth_method =
94
+ this.config.tokenEndpointAuthMethod || "none";
95
+ }
96
+
97
+ this.client = new this.issuer.Client(clientMetadata);
74
98
 
75
99
  this.initialized = true;
76
100
  } catch (error: any) {
@@ -129,8 +153,14 @@ export class OidcProvider {
129
153
  }
130
154
  );
131
155
 
156
+ // Raw response from the token endpoint (includes access_token, id_token,
157
+ // refresh_token, token_type, expires_at, scope, and any non-standard fields
158
+ // returned by the IdP). Trace-level because it contains secrets.
159
+ oidcLog.trace(`Provider "${this.config.issuer}": raw token endpoint response`, { ...tokenSet });
160
+
132
161
  // Extract claims from ID token (already validated by openid-client)
133
162
  const claims = tokenSet.claims();
163
+ oidcLog.trace(`Provider "${this.config.issuer}": extracted claims from ID token`, claims);
134
164
 
135
165
  return {
136
166
  accessToken: tokenSet.access_token!,
@@ -142,6 +172,15 @@ export class OidcProvider {
142
172
  claims,
143
173
  };
144
174
  } catch (error: any) {
175
+ // openid-client attaches the raw IdP error response on OPError.
176
+ oidcLog.trace(`Provider "${this.config.issuer}": raw token exchange error`, {
177
+ name: error.name,
178
+ message: error.message,
179
+ error: error.error,
180
+ error_description: error.error_description,
181
+ error_uri: error.error_uri,
182
+ response: error.response?.body ?? error.response,
183
+ });
145
184
  throw createOidcError(OidcErrorCodes.TOKEN_EXCHANGE_FAILED, `Token exchange failed: ${error.message}`, {
146
185
  originalError: error.message,
147
186
  errorCode: error.error,
@@ -163,8 +202,17 @@ export class OidcProvider {
163
202
 
164
203
  try {
165
204
  const userinfo = await this.client!.userinfo(accessToken);
205
+ // Raw response from the UserInfo endpoint, before any mapping/merging.
206
+ oidcLog.trace(`Provider "${this.config.issuer}": raw UserInfo endpoint response`, userinfo);
166
207
  return userinfo;
167
208
  } catch (error: any) {
209
+ oidcLog.trace(`Provider "${this.config.issuer}": raw UserInfo error`, {
210
+ name: error.name,
211
+ message: error.message,
212
+ error: error.error,
213
+ error_description: error.error_description,
214
+ response: error.response?.body ?? error.response,
215
+ });
168
216
  throw createOidcError(OidcErrorCodes.USERINFO_FAILED, `UserInfo request failed: ${error.message}`, {
169
217
  originalError: error.message,
170
218
  });
@@ -185,14 +233,17 @@ export class OidcProvider {
185
233
  let claims = { ...tokenSet.claims };
186
234
 
187
235
  // Optionally fetch additional claims from UserInfo endpoint
188
- if (includeUserInfo && this.config.userinfoEndpoint) {
236
+ // Check both manual config and discovered endpoint (via OIDC discovery)
237
+ const userinfoUrl = this.config.userinfoEndpoint || this.issuer?.metadata?.userinfo_endpoint;
238
+
239
+ if (includeUserInfo && userinfoUrl) {
189
240
  try {
190
241
  const userinfo = await this.getUserInfo(tokenSet.accessToken);
191
242
  // Merge UserInfo claims with ID token claims
192
243
  claims = { ...claims, ...userinfo };
193
244
  } catch (error) {
194
245
  // UserInfo is optional - continue with ID token claims only
195
- console.warn("Failed to fetch UserInfo, using ID token claims only:", error);
246
+ oidcLog.warn(`Failed to fetch UserInfo from ${userinfoUrl}, using ID token claims only:`, error);
196
247
  }
197
248
  }
198
249
 
@@ -200,10 +251,12 @@ export class OidcProvider {
200
251
  if (this.config.claimMapping) {
201
252
  const customClaims = extractCustomClaims(claims, this.config.claimMapping);
202
253
  claims = { ...claims, ...customClaims };
254
+ oidcLog.trace(`Provider "${this.config.issuer}": applied custom claim mapping`, claims);
203
255
  }
204
256
 
205
257
  // Map to normalized profile
206
258
  const profile = mapClaimsToProfile(claims);
259
+ oidcLog.trace(`Provider "${this.config.issuer}": built final profile`, profile);
207
260
 
208
261
  return profile;
209
262
  }
@@ -1,6 +1,7 @@
1
1
  import { OidcProvider } from "./OidcProvider";
2
2
  import { OidcProviderConfig } from "../OidcProviderConfig";
3
3
  import { createOidcError, OidcErrorCodes } from "../utils/error-utils";
4
+ import { oidcLog, maskSecret } from "../log";
4
5
 
5
6
  /**
6
7
  * Provider registry for managing OIDC provider instances
@@ -14,14 +15,17 @@ import { createOidcError, OidcErrorCodes } from "../utils/error-utils";
14
15
  export class ProviderRegistry {
15
16
  private staticProviders: Record<string, OidcProviderConfig>;
16
17
  private providerInstances: Map<string, OidcProvider> = new Map();
17
- private providerLoader?: (providerName: string) => Promise<OidcProviderConfig | null>;
18
+ private providerLoader?: (providerName: string, ctx: any) => Promise<OidcProviderConfig | null>;
19
+ private ctx: any;
18
20
 
19
21
  constructor(
20
22
  staticProviders: Record<string, OidcProviderConfig>,
21
- providerLoader?: (providerName: string) => Promise<OidcProviderConfig | null>
23
+ providerLoader?: (providerName: string, ctx: any) => Promise<OidcProviderConfig | null>,
24
+ ctx?: any
22
25
  ) {
23
26
  this.staticProviders = staticProviders;
24
27
  this.providerLoader = providerLoader;
28
+ this.ctx = ctx;
25
29
  }
26
30
 
27
31
  /**
@@ -42,24 +46,39 @@ export class ProviderRegistry {
42
46
  // Check cache first
43
47
  const cachedProvider = this.providerInstances.get(providerName);
44
48
  if (cachedProvider) {
49
+ oidcLog.debug(`Provider "${providerName}": using cached instance`);
45
50
  return cachedProvider;
46
51
  }
47
52
 
48
53
  // Try static configuration
49
54
  let config: OidcProviderConfig | null = this.staticProviders[providerName] || null;
55
+ let configSource = "static";
50
56
 
51
57
  // Try dynamic loader if not in static config
52
58
  if (!config && this.providerLoader) {
53
- config = await this.providerLoader(providerName);
59
+ oidcLog.debug(`Provider "${providerName}": not in static config, invoking providerLoader`);
60
+ config = await this.providerLoader(providerName, this.ctx);
61
+ configSource = "dynamic";
54
62
  }
55
63
 
56
64
  if (!config) {
65
+ oidcLog.debug(`Provider "${providerName}": not found (static=[${Object.keys(this.staticProviders).join(", ")}], loader=${!!this.providerLoader})`);
57
66
  throw createOidcError(OidcErrorCodes.PROVIDER_NOT_CONFIGURED, `OIDC provider '${providerName}' is not configured`, {
58
67
  providerName,
59
68
  availableProviders: Object.keys(this.staticProviders),
60
69
  });
61
70
  }
62
71
 
72
+ oidcLog.debug(
73
+ `Provider "${providerName}" resolved via ${configSource}:`,
74
+ `issuer=${config.issuer}`,
75
+ `clientId=${config.clientId}`,
76
+ `clientSecret=${maskSecret(config.clientSecret)}`,
77
+ `callbackUrl=${config.callbackUrl}`,
78
+ config.discoveryUrl ? `discoveryUrl=${config.discoveryUrl}` : `authorizationEndpoint=${config.authorizationEndpoint}`,
79
+ `scope=${(config.scope || ["openid", "email", "profile"]).join(" ")}`
80
+ );
81
+
63
82
  // Create and initialize provider
64
83
  const provider = new OidcProvider(config);
65
84
  await provider.initialize();
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * GET /oidc/:provider/callback?code=...&state=...&response_type=json
5
5
  */
6
- export default interface CallbackRequest {
6
+ type CallbackRequest = Record<string, string | string[]> & {
7
7
  /**
8
8
  * Authorization code from the IdP
9
9
  * Required for successful authentication
@@ -34,8 +34,6 @@ export default interface CallbackRequest {
34
34
  */
35
35
  response_type?: "json";
36
36
 
37
- /**
38
- * Index signature for Flink Query type compatibility
39
- */
40
- [key: string]: string | string[] | undefined;
41
- }
37
+ };
38
+
39
+ export default CallbackRequest;
@@ -3,15 +3,12 @@
3
3
  *
4
4
  * GET /oidc/:provider/initiate?redirectUri=...
5
5
  */
6
- export default interface InitiateRequest {
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
+ };
12
13
 
13
- /**
14
- * Index signature for Flink Query type compatibility
15
- */
16
- [key: string]: string | string[] | undefined;
17
- }
14
+ export default InitiateRequest;
@@ -44,6 +44,12 @@ export default interface OidcSession {
44
44
  */
45
45
  redirectUri: string;
46
46
 
47
+ /**
48
+ * Arbitrary metadata collected from meta.* query parameters on initiate
49
+ * Passed through to onAuthSuccess and onAuthError callbacks unchanged
50
+ */
51
+ metadata?: Record<string, string>;
52
+
47
53
  /**
48
54
  * Session creation timestamp
49
55
  * MongoDB TTL index will automatically delete expired sessions
@@ -1,20 +1,38 @@
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";
14
+
1
15
  /**
2
16
  * Format token response for the client
3
17
  *
4
18
  * Supports two response formats:
5
19
  * 1. JSON response (for API clients)
6
- * 2. Redirect with token in URL fragment (for web browsers)
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.
20
+ * 2. Redirect with token in URL fragment or query (for web/native clients)
10
21
  *
11
22
  * @param token - JWT token for the application
12
23
  * @param user - User object
13
24
  * @param redirectUrl - URL to redirect to
14
25
  * @param responseType - "json" or undefined (redirect)
26
+ * @param tokenDelivery - How to attach the token on redirect. Defaults to "fragment".
15
27
  * @returns Flink response object
16
28
  */
17
- export function formatTokenResponse(token: string, user: any, redirectUrl: string, responseType?: "json"): any {
29
+ export function formatTokenResponse(
30
+ token: string,
31
+ user: any,
32
+ redirectUrl: string,
33
+ responseType?: "json",
34
+ tokenDelivery: TokenDeliveryMode = "fragment"
35
+ ): any {
18
36
  // JSON response for API clients
19
37
  if (responseType === "json") {
20
38
  return {
@@ -25,11 +43,8 @@ export function formatTokenResponse(token: string, user: any, redirectUrl: strin
25
43
  };
26
44
  }
27
45
 
28
- // Redirect response for web browsers
29
- // Token is in URL fragment (#token=...) for security
30
- const separator = redirectUrl.includes("#") ? "&" : "#";
31
- const tokenFragment = `token=${encodeURIComponent(token)}`;
32
- const finalUrl = `${redirectUrl}${separator}${tokenFragment}`;
46
+ const tokenParam = `token=${encodeURIComponent(token)}`;
47
+ const finalUrl = appendToken(redirectUrl, tokenParam, tokenDelivery);
33
48
 
34
49
  return {
35
50
  status: 302,
@@ -39,3 +54,31 @@ export function formatTokenResponse(token: string, user: any, redirectUrl: strin
39
54
  data: {},
40
55
  };
41
56
  }
57
+
58
+ /**
59
+ * Append a token parameter to a URL using the specified delivery mode.
60
+ *
61
+ * For "query" mode, any existing `#fragment` on the URL is preserved: the
62
+ * query parameter is spliced in *before* the fragment so the final URL is
63
+ * still well-formed.
64
+ *
65
+ * For "fragment" mode, a second token on an existing fragment is joined
66
+ * with `&` (same as the old behavior).
67
+ */
68
+ function appendToken(redirectUrl: string, tokenParam: string, mode: TokenDeliveryMode): string {
69
+ if (mode === "query") {
70
+ const hashIdx = redirectUrl.indexOf("#");
71
+ if (hashIdx >= 0) {
72
+ const base = redirectUrl.slice(0, hashIdx);
73
+ const frag = redirectUrl.slice(hashIdx);
74
+ const sep = base.includes("?") ? "&" : "?";
75
+ return `${base}${sep}${tokenParam}${frag}`;
76
+ }
77
+ const sep = redirectUrl.includes("?") ? "&" : "?";
78
+ return `${redirectUrl}${sep}${tokenParam}`;
79
+ }
80
+
81
+ // fragment (default)
82
+ const sep = redirectUrl.includes("#") ? "&" : "#";
83
+ return `${redirectUrl}${sep}${tokenParam}`;
84
+ }