@seamless-auth/oidc-provider 0.0.0-stage → 0.1.0
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 +59 -0
- package/LICENSE +201 -0
- package/README.md +273 -2
- package/dist/adapter.d.ts +44 -0
- package/dist/adapter.js +249 -0
- package/dist/adapter.js.map +1 -0
- package/dist/apiClient.d.ts +35 -0
- package/dist/apiClient.js +217 -0
- package/dist/apiClient.js.map +1 -0
- package/dist/authRequirements.d.ts +37 -0
- package/dist/authRequirements.js +58 -0
- package/dist/authRequirements.js.map +1 -0
- package/dist/clientSecrets.d.ts +10 -0
- package/dist/clientSecrets.js +102 -0
- package/dist/clientSecrets.js.map +1 -0
- package/dist/configCache.d.ts +14 -0
- package/dist/configCache.js +45 -0
- package/dist/configCache.js.map +1 -0
- package/dist/createOidcEdge.d.ts +45 -0
- package/dist/createOidcEdge.js +132 -0
- package/dist/createOidcEdge.js.map +1 -0
- package/dist/deviceFlow.d.ts +53 -0
- package/dist/deviceFlow.js +168 -0
- package/dist/deviceFlow.js.map +1 -0
- package/dist/env.d.ts +16 -0
- package/dist/env.js +36 -0
- package/dist/env.js.map +1 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +40 -0
- package/dist/errors.js.map +1 -0
- package/dist/firstPartySession.d.ts +21 -0
- package/dist/firstPartySession.js +28 -0
- package/dist/firstPartySession.js.map +1 -0
- package/dist/hostedPages.d.ts +51 -0
- package/dist/hostedPages.js +172 -0
- package/dist/hostedPages.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/interactions.d.ts +17 -0
- package/dist/interactions.js +209 -0
- package/dist/interactions.js.map +1 -0
- package/dist/jwtAccessTokens.d.ts +23 -0
- package/dist/jwtAccessTokens.js +125 -0
- package/dist/jwtAccessTokens.js.map +1 -0
- package/dist/logout.d.ts +22 -0
- package/dist/logout.js +86 -0
- package/dist/logout.js.map +1 -0
- package/dist/originGuard.d.ts +10 -0
- package/dist/originGuard.js +32 -0
- package/dist/originGuard.js.map +1 -0
- package/dist/pkce.d.ts +16 -0
- package/dist/pkce.js +32 -0
- package/dist/pkce.js.map +1 -0
- package/dist/providerConfig.d.ts +50 -0
- package/dist/providerConfig.js +305 -0
- package/dist/providerConfig.js.map +1 -0
- package/dist/rateLimit.d.ts +13 -0
- package/dist/rateLimit.js +36 -0
- package/dist/rateLimit.js.map +1 -0
- package/dist/refreshReuse.d.ts +3 -0
- package/dist/refreshReuse.js +56 -0
- package/dist/refreshReuse.js.map +1 -0
- package/dist/requestContext.d.ts +40 -0
- package/dist/requestContext.js +43 -0
- package/dist/requestContext.js.map +1 -0
- package/dist/resourceServers.d.ts +16 -0
- package/dist/resourceServers.js +59 -0
- package/dist/resourceServers.js.map +1 -0
- package/dist/resumeCookie.d.ts +8 -0
- package/dist/resumeCookie.js +46 -0
- package/dist/resumeCookie.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +36 -0
- package/dist/server.js.map +1 -0
- package/dist/signingKey.d.ts +29 -0
- package/dist/signingKey.js +142 -0
- package/dist/signingKey.js.map +1 -0
- package/dist/startup.d.ts +4 -0
- package/dist/startup.js +19 -0
- package/dist/startup.js.map +1 -0
- package/dist/tokenAudit.d.ts +4 -0
- package/dist/tokenAudit.js +42 -0
- package/dist/tokenAudit.js.map +1 -0
- package/dist/tokenLifetimes.d.ts +31 -0
- package/dist/tokenLifetimes.js +51 -0
- package/dist/tokenLifetimes.js.map +1 -0
- package/dist/ui/assets/index-CydLXRvo.js +18 -0
- package/dist/ui/assets/index-y3LME-Gv.css +1 -0
- package/dist/ui/index.html +15 -0
- package/package.json +115 -4
package/dist/env.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"env.js","sourceRoot":"","sources":["../src/env.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,MAAM,iBAAiB,GAAG,EAAE,CAAC;AAE7B,MAAM,aAAa,GAAG,CAAC;KACpB,IAAI,CAAC,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KACvB,OAAO,CAAC,OAAO,CAAC;KAChB,SAAS,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,KAAK,MAAM,CAAC,CAAC;AAE1C,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,CAAC,MAAM,CAAC;IAChC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC;IAC7D,gBAAgB,EAAE,CAAC,CAAC,GAAG,EAAE;IACzB,mBAAmB,EAAE,CAAC,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE;IACvC,2BAA2B,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,iBAAiB,CAAC;IAC9D,iBAAiB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE;IAC/C,sBAAsB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,iBAAiB,CAAC;IACzD,gBAAgB,EAAE,CAAC;SAChB,MAAM,EAAE;SACR,SAAS,CAAC,CAAC,KAAK,EAAE,EAAE,CACnB,KAAK;SACF,KAAK,CAAC,GAAG,CAAC;SACV,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;SACxB,MAAM,CAAC,OAAO,CAAC,CACnB;SACA,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IAC1D,WAAW,EAAE,aAAa;CAC3B,CAAC,CAAC;AAIH,MAAM,UAAU,QAAQ,CAAC,SAA4B,OAAO,CAAC,GAAG;IAC9D,MAAM,MAAM,GAAG,SAAS,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;IAC3C,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;QACpB,MAAM,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACnF,MAAM,IAAI,KAAK,CAAC,wBAAwB,MAAM,EAAE,CAAC,CAAC;IACpD,CAAC;IACD,OAAO,MAAM,CAAC,IAAI,CAAC;AACrB,CAAC"}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { OidcSignRefusalCode } from '@seamless-auth/types';
|
|
2
|
+
export declare class OidcApiError extends Error {
|
|
3
|
+
readonly status: number;
|
|
4
|
+
readonly path: string;
|
|
5
|
+
constructor(status: number, path: string, message?: string);
|
|
6
|
+
}
|
|
7
|
+
export declare class OidcSignRefusedError extends Error {
|
|
8
|
+
readonly code: OidcSignRefusalCode;
|
|
9
|
+
constructor(code: OidcSignRefusalCode, message?: string);
|
|
10
|
+
}
|
|
11
|
+
export declare class OidcProviderDisabledError extends Error {
|
|
12
|
+
constructor();
|
|
13
|
+
}
|
|
14
|
+
export declare class OidcContractMismatchError extends Error {
|
|
15
|
+
readonly apiVersion: number;
|
|
16
|
+
readonly edgeVersion: number;
|
|
17
|
+
constructor(apiVersion: number, edgeVersion: number);
|
|
18
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright © 2026 Fells Code, LLC
|
|
3
|
+
* Licensed under the Apache License, Version 2.0
|
|
4
|
+
* See LICENSE file in the project root for full license information
|
|
5
|
+
*/
|
|
6
|
+
export class OidcApiError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
path;
|
|
9
|
+
constructor(status, path, message = `Seamless Auth API answered ${status} for ${path}`) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.path = path;
|
|
13
|
+
this.name = 'OidcApiError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class OidcSignRefusedError extends Error {
|
|
17
|
+
code;
|
|
18
|
+
constructor(code, message = `Seamless Auth API refused to sign: ${code}`) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.name = 'OidcSignRefusedError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export class OidcProviderDisabledError extends Error {
|
|
25
|
+
constructor() {
|
|
26
|
+
super('The Seamless Auth API has the OpenID Provider disabled (oidc_provider_enabled is off)');
|
|
27
|
+
this.name = 'OidcProviderDisabledError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export class OidcContractMismatchError extends Error {
|
|
31
|
+
apiVersion;
|
|
32
|
+
edgeVersion;
|
|
33
|
+
constructor(apiVersion, edgeVersion) {
|
|
34
|
+
super(`The Seamless Auth API speaks /oidc contract version ${apiVersion}, this service speaks ${edgeVersion}`);
|
|
35
|
+
this.apiVersion = apiVersion;
|
|
36
|
+
this.edgeVersion = edgeVersion;
|
|
37
|
+
this.name = 'OidcContractMismatchError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,MAAM,OAAO,YAAa,SAAQ,KAAK;IAE1B;IACA;IAFX,YACW,MAAc,EACd,IAAY,EACrB,OAAO,GAAG,8BAA8B,MAAM,QAAQ,IAAI,EAAE;QAE5D,KAAK,CAAC,OAAO,CAAC,CAAC;QAJN,WAAM,GAAN,MAAM,CAAQ;QACd,SAAI,GAAJ,IAAI,CAAQ;QAIrB,IAAI,CAAC,IAAI,GAAG,cAAc,CAAC;IAC7B,CAAC;CACF;AAED,MAAM,OAAO,oBAAqB,SAAQ,KAAK;IAElC;IADX,YACW,IAAyB,EAClC,OAAO,GAAG,sCAAsC,IAAI,EAAE;QAEtD,KAAK,CAAC,OAAO,CAAC,CAAC;QAHN,SAAI,GAAJ,IAAI,CAAqB;QAIlC,IAAI,CAAC,IAAI,GAAG,sBAAsB,CAAC;IACrC,CAAC;CACF;AAED,MAAM,OAAO,yBAA0B,SAAQ,KAAK;IAClD;QACE,KAAK,CAAC,uFAAuF,CAAC,CAAC;QAC/F,IAAI,CAAC,IAAI,GAAG,2BAA2B,CAAC;IAC1C,CAAC;CACF;AAED,MAAM,OAAO,yBAA0B,SAAQ,KAAK;IAEvC;IACA;IAFX,YACW,UAAkB,EAClB,WAAmB;QAE5B,KAAK,CACH,uDAAuD,UAAU,yBAAyB,WAAW,EAAE,CACxG,CAAC;QALO,eAAU,GAAV,UAAU,CAAQ;QAClB,gBAAW,GAAX,WAAW,CAAQ;QAK5B,IAAI,CAAC,IAAI,GAAG,2BAA2B,CAAC;IAC1C,CAAC;CACF"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface FirstPartySession {
|
|
2
|
+
userId: string;
|
|
3
|
+
sessionId: string;
|
|
4
|
+
}
|
|
5
|
+
export interface FirstPartySessionRejection {
|
|
6
|
+
status: number;
|
|
7
|
+
error: string;
|
|
8
|
+
}
|
|
9
|
+
export type FirstPartySessionResult = {
|
|
10
|
+
session: FirstPartySession;
|
|
11
|
+
rejection?: undefined;
|
|
12
|
+
} | {
|
|
13
|
+
session?: undefined;
|
|
14
|
+
rejection: FirstPartySessionRejection;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Reads the first-party session the hosted pages signed in through this service's adapter.
|
|
18
|
+
* A recovery session is refused here, as it is on every first-party route that is not
|
|
19
|
+
* enrollment, so it can never finish a provider sign-in.
|
|
20
|
+
*/
|
|
21
|
+
export declare function readFirstPartySession(cookie: string | undefined, cookieSecret: string): FirstPartySessionResult;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright © 2026 Fells Code, LLC
|
|
3
|
+
* Licensed under the Apache License, Version 2.0
|
|
4
|
+
* See LICENSE file in the project root for full license information
|
|
5
|
+
*/
|
|
6
|
+
import { authenticateCookie, verifyCookieJwt } from '@seamless-auth/core';
|
|
7
|
+
/**
|
|
8
|
+
* Reads the first-party session the hosted pages signed in through this service's adapter.
|
|
9
|
+
* A recovery session is refused here, as it is on every first-party route that is not
|
|
10
|
+
* enrollment, so it can never finish a provider sign-in.
|
|
11
|
+
*/
|
|
12
|
+
export function readFirstPartySession(cookie, cookieSecret) {
|
|
13
|
+
const { user, rejection } = authenticateCookie({ token: cookie, cookieSecret });
|
|
14
|
+
if (rejection || !user || !cookie) {
|
|
15
|
+
return {
|
|
16
|
+
rejection: {
|
|
17
|
+
status: rejection?.status ?? 401,
|
|
18
|
+
error: rejection?.errorCode === 'enrollment_required' ? 'enrollment_required' : 'login_required',
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
const payload = verifyCookieJwt(cookie, cookieSecret);
|
|
23
|
+
if (typeof payload?.sessionId !== 'string' || !payload.sessionId) {
|
|
24
|
+
return { rejection: { status: 401, error: 'login_required' } };
|
|
25
|
+
}
|
|
26
|
+
return { session: { userId: user.id, sessionId: payload.sessionId } };
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=firstPartySession.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"firstPartySession.js","sourceRoot":"","sources":["../src/firstPartySession.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAgB1E;;;;GAIG;AACH,MAAM,UAAU,qBAAqB,CACnC,MAA0B,EAC1B,YAAoB;IAEpB,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,kBAAkB,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,CAAC,CAAC;IAChF,IAAI,SAAS,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;QAClC,OAAO;YACL,SAAS,EAAE;gBACT,MAAM,EAAE,SAAS,EAAE,MAAM,IAAI,GAAG;gBAChC,KAAK,EACH,SAAS,EAAE,SAAS,KAAK,qBAAqB,CAAC,CAAC,CAAC,qBAAqB,CAAC,CAAC,CAAC,gBAAgB;aAC5F;SACF,CAAC;IACJ,CAAC;IACD,MAAM,OAAO,GAAG,eAAe,CAA0B,MAAM,EAAE,YAAY,CAAC,CAAC;IAC/E,IAAI,OAAO,OAAO,EAAE,SAAS,KAAK,QAAQ,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,CAAC;QACjE,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,gBAAgB,EAAE,EAAE,CAAC;IACjE,CAAC;IACD,OAAO,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,EAAE,CAAC;AACxE,CAAC"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { HostedUi } from '@seamless-auth/types';
|
|
2
|
+
import { type Router } from 'express';
|
|
3
|
+
import type { EdgeConfigCache } from './configCache.js';
|
|
4
|
+
/**
|
|
5
|
+
* Paths the SPA renders. The adapter's bundled screens (`@seamless-auth/react` AuthRoutes)
|
|
6
|
+
* own most of them, and the API builds magic link and recovery URLs from some.
|
|
7
|
+
*/
|
|
8
|
+
export declare const HOSTED_PAGE_PATHS: readonly ["/", "/interaction/:uid", "/login", "/passkey-login", "/verify-phone-otp", "/verify-email-otp", "/verify-magiclink", "/oauth/callback", "/register-passkey", "/magic-link-sent", "/recovery-code", "/recovery-codes", "/account-recovery", "/recovery/cancel"];
|
|
9
|
+
export interface HostedBranding {
|
|
10
|
+
appName: string;
|
|
11
|
+
logoUrl?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface HostedPagesOptions {
|
|
14
|
+
/** The built SPA: `index.html` and its `assets/`. */
|
|
15
|
+
uiDir: string;
|
|
16
|
+
configCache: EdgeConfigCache;
|
|
17
|
+
}
|
|
18
|
+
/** JSON for an inline `<script type="application/json">`, safe against `</script>`. */
|
|
19
|
+
export declare function inlineJson(value: unknown): string;
|
|
20
|
+
/**
|
|
21
|
+
* ADR D7's CSP. The bundled screens inject their CSS at runtime, so injected `<style>`
|
|
22
|
+
* elements take the response's nonce (see `ui/src/nonce.ts`). Scripts stay `'self'` only.
|
|
23
|
+
*/
|
|
24
|
+
export declare function contentSecurityPolicy(nonce: string, logoUrl?: string, formActions?: readonly string[]): string;
|
|
25
|
+
export interface BrandingStyle {
|
|
26
|
+
css: string;
|
|
27
|
+
/** Set when the branding has colors, which turns off the stylesheet's dark palette. */
|
|
28
|
+
palette: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* `hosted_ui` as CSS variables. The API and this service's config client both validate it
|
|
32
|
+
* against `HostedUiSchema`, which only admits hex colors and plain font names.
|
|
33
|
+
*/
|
|
34
|
+
export declare function brandingStyle(hostedUi: HostedUi | undefined): BrandingStyle | undefined;
|
|
35
|
+
export declare function brandingFor(appName: string, hostedUi: HostedUi | undefined): HostedBranding;
|
|
36
|
+
/** A page the provider renders itself (logout, device flow), handed to the SPA as data. */
|
|
37
|
+
export type HostedPageData = Record<string, unknown> & {
|
|
38
|
+
view: string;
|
|
39
|
+
};
|
|
40
|
+
export declare function renderIndex(template: string, branding: HostedBranding, nonce: string, style?: BrandingStyle, page?: HostedPageData): string;
|
|
41
|
+
export interface RenderedPage {
|
|
42
|
+
html: string;
|
|
43
|
+
headers: Record<string, string>;
|
|
44
|
+
}
|
|
45
|
+
export type HostedPageRenderer = (page?: HostedPageData, formActions?: readonly string[]) => Promise<RenderedPage>;
|
|
46
|
+
export declare function createHostedPageRenderer(options: HostedPagesOptions): HostedPageRenderer;
|
|
47
|
+
/**
|
|
48
|
+
* Serves the hosted login and consent pages. Branding is read from the API's config on each
|
|
49
|
+
* page load and written into `index.html`, so changing it is a config write, never a build.
|
|
50
|
+
*/
|
|
51
|
+
export declare function createHostedPagesRouter(options: HostedPagesOptions, render?: HostedPageRenderer): Router;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright © 2026 Fells Code, LLC
|
|
3
|
+
* Licensed under the Apache License, Version 2.0
|
|
4
|
+
* See LICENSE file in the project root for full license information
|
|
5
|
+
*/
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import express from 'express';
|
|
10
|
+
/**
|
|
11
|
+
* Paths the SPA renders. The adapter's bundled screens (`@seamless-auth/react` AuthRoutes)
|
|
12
|
+
* own most of them, and the API builds magic link and recovery URLs from some.
|
|
13
|
+
*/
|
|
14
|
+
export const HOSTED_PAGE_PATHS = [
|
|
15
|
+
'/',
|
|
16
|
+
'/interaction/:uid',
|
|
17
|
+
'/login',
|
|
18
|
+
'/passkey-login',
|
|
19
|
+
'/verify-phone-otp',
|
|
20
|
+
'/verify-email-otp',
|
|
21
|
+
'/verify-magiclink',
|
|
22
|
+
'/oauth/callback',
|
|
23
|
+
'/register-passkey',
|
|
24
|
+
'/magic-link-sent',
|
|
25
|
+
'/recovery-code',
|
|
26
|
+
'/recovery-codes',
|
|
27
|
+
'/account-recovery',
|
|
28
|
+
'/recovery/cancel',
|
|
29
|
+
];
|
|
30
|
+
const HEAD_PLACEHOLDER = '<!--seamless:head-->';
|
|
31
|
+
const COLOR_VARIABLES = {
|
|
32
|
+
primary: '--seamless-color-primary',
|
|
33
|
+
onPrimary: '--seamless-color-on-primary',
|
|
34
|
+
background: '--seamless-color-background',
|
|
35
|
+
surface: '--seamless-color-surface',
|
|
36
|
+
text: '--seamless-color-text',
|
|
37
|
+
muted: '--seamless-color-muted',
|
|
38
|
+
};
|
|
39
|
+
/** JSON for an inline `<script type="application/json">`, safe against `</script>`. */
|
|
40
|
+
export function inlineJson(value) {
|
|
41
|
+
return JSON.stringify(value)
|
|
42
|
+
.replace(/</g, '\\u003c')
|
|
43
|
+
.replace(/>/g, '\\u003e')
|
|
44
|
+
.replace(/&/g, '\\u0026')
|
|
45
|
+
.replace(/\u2028/g, '\\u2028')
|
|
46
|
+
.replace(/\u2029/g, '\\u2029');
|
|
47
|
+
}
|
|
48
|
+
function escapeHtml(value) {
|
|
49
|
+
return value
|
|
50
|
+
.replace(/&/g, '&')
|
|
51
|
+
.replace(/</g, '<')
|
|
52
|
+
.replace(/>/g, '>')
|
|
53
|
+
.replace(/"/g, '"')
|
|
54
|
+
.replace(/'/g, ''');
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* ADR D7's CSP. The bundled screens inject their CSS at runtime, so injected `<style>`
|
|
58
|
+
* elements take the response's nonce (see `ui/src/nonce.ts`). Scripts stay `'self'` only.
|
|
59
|
+
*/
|
|
60
|
+
export function contentSecurityPolicy(nonce, logoUrl, formActions = []) {
|
|
61
|
+
const logoOrigin = logoUrl ? ` ${new URL(logoUrl).origin}` : '';
|
|
62
|
+
// Browsers apply form-action to the redirects that follow a submission, so a form that
|
|
63
|
+
// ends at an app's post-logout URI has to name that origin.
|
|
64
|
+
const formOrigins = formActions.map((uri) => ` ${new URL(uri).origin}`).join('');
|
|
65
|
+
return [
|
|
66
|
+
"default-src 'none'",
|
|
67
|
+
"script-src 'self'",
|
|
68
|
+
`style-src 'self' 'nonce-${nonce}'`,
|
|
69
|
+
`img-src 'self' data:${logoOrigin}`,
|
|
70
|
+
"font-src 'self'",
|
|
71
|
+
"connect-src 'self'",
|
|
72
|
+
"manifest-src 'self'",
|
|
73
|
+
`form-action 'self'${formOrigins}`,
|
|
74
|
+
"frame-ancestors 'none'",
|
|
75
|
+
"base-uri 'none'",
|
|
76
|
+
].join('; ');
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* `hosted_ui` as CSS variables. The API and this service's config client both validate it
|
|
80
|
+
* against `HostedUiSchema`, which only admits hex colors and plain font names.
|
|
81
|
+
*/
|
|
82
|
+
export function brandingStyle(hostedUi) {
|
|
83
|
+
const declarations = [];
|
|
84
|
+
for (const [key, variable] of Object.entries(COLOR_VARIABLES)) {
|
|
85
|
+
const value = hostedUi?.colors?.[key];
|
|
86
|
+
if (value) {
|
|
87
|
+
declarations.push(`${variable}:${value}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const palette = declarations.length > 0;
|
|
91
|
+
if (palette) {
|
|
92
|
+
declarations.push('color-scheme:light');
|
|
93
|
+
}
|
|
94
|
+
if (hostedUi?.fontFamily) {
|
|
95
|
+
declarations.push(`--seamless-font-family:${hostedUi.fontFamily}`);
|
|
96
|
+
}
|
|
97
|
+
return declarations.length > 0
|
|
98
|
+
? { css: `:root:root{${declarations.join(';')}}`, palette }
|
|
99
|
+
: undefined;
|
|
100
|
+
}
|
|
101
|
+
export function brandingFor(appName, hostedUi) {
|
|
102
|
+
return {
|
|
103
|
+
appName: hostedUi?.appName ?? appName,
|
|
104
|
+
...(hostedUi?.logoUrl ? { logoUrl: hostedUi.logoUrl } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export function renderIndex(template, branding, nonce, style, page) {
|
|
108
|
+
const head = [
|
|
109
|
+
`<meta name="seamless-csp-nonce" content="${nonce}" />`,
|
|
110
|
+
`<title>Sign in to ${escapeHtml(branding.appName)}</title>`,
|
|
111
|
+
`<script type="application/json" id="seamless-hosted">${inlineJson(branding)}</script>`,
|
|
112
|
+
...(page
|
|
113
|
+
? [`<script type="application/json" id="seamless-page">${inlineJson(page)}</script>`]
|
|
114
|
+
: []),
|
|
115
|
+
...(style
|
|
116
|
+
? [
|
|
117
|
+
`<style nonce="${nonce}"${style.palette ? ' id="seamless-palette"' : ''}>${style.css}</style>`,
|
|
118
|
+
]
|
|
119
|
+
: []),
|
|
120
|
+
].join('\n ');
|
|
121
|
+
return template.replace(HEAD_PLACEHOLDER, head);
|
|
122
|
+
}
|
|
123
|
+
function pageHeaders(nonce, logoUrl, formActions) {
|
|
124
|
+
return {
|
|
125
|
+
'content-security-policy': contentSecurityPolicy(nonce, logoUrl, formActions),
|
|
126
|
+
'cache-control': 'no-store',
|
|
127
|
+
'cross-origin-opener-policy': 'same-origin',
|
|
128
|
+
'referrer-policy': 'no-referrer',
|
|
129
|
+
'x-content-type-options': 'nosniff',
|
|
130
|
+
'x-frame-options': 'DENY',
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
export function createHostedPageRenderer(options) {
|
|
134
|
+
const template = readFileSync(join(options.uiDir, 'index.html'), 'utf8');
|
|
135
|
+
if (!template.includes(HEAD_PLACEHOLDER)) {
|
|
136
|
+
throw new Error(`${options.uiDir}/index.html has no ${HEAD_PLACEHOLDER} placeholder`);
|
|
137
|
+
}
|
|
138
|
+
return async (page, formActions) => {
|
|
139
|
+
const config = await options.configCache.get();
|
|
140
|
+
const nonce = randomBytes(16).toString('base64');
|
|
141
|
+
const branding = brandingFor(config.appName, config.hostedUi);
|
|
142
|
+
return {
|
|
143
|
+
html: renderIndex(template, branding, nonce, brandingStyle(config.hostedUi), page),
|
|
144
|
+
headers: pageHeaders(nonce, branding.logoUrl, formActions),
|
|
145
|
+
};
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Serves the hosted login and consent pages. Branding is read from the API's config on each
|
|
150
|
+
* page load and written into `index.html`, so changing it is a config write, never a build.
|
|
151
|
+
*/
|
|
152
|
+
export function createHostedPagesRouter(options, render = createHostedPageRenderer(options)) {
|
|
153
|
+
const router = express.Router();
|
|
154
|
+
router.use('/assets', express.static(join(options.uiDir, 'assets'), {
|
|
155
|
+
fallthrough: false,
|
|
156
|
+
immutable: true,
|
|
157
|
+
maxAge: '365d',
|
|
158
|
+
index: false,
|
|
159
|
+
setHeaders: (res) => {
|
|
160
|
+
res.setHeader('x-content-type-options', 'nosniff');
|
|
161
|
+
},
|
|
162
|
+
}));
|
|
163
|
+
router.get([...HOSTED_PAGE_PATHS], (_req, res, next) => {
|
|
164
|
+
render()
|
|
165
|
+
.then(({ html, headers }) => {
|
|
166
|
+
res.set(headers).type('html').send(html);
|
|
167
|
+
})
|
|
168
|
+
.catch(next);
|
|
169
|
+
});
|
|
170
|
+
return router;
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=hostedPages.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hostedPages.js","sourceRoot":"","sources":["../src/hostedPages.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAGjC,OAAO,OAAwB,MAAM,SAAS,CAAC;AAI/C;;;GAGG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG;IAC/B,GAAG;IACH,mBAAmB;IACnB,QAAQ;IACR,gBAAgB;IAChB,mBAAmB;IACnB,mBAAmB;IACnB,mBAAmB;IACnB,iBAAiB;IACjB,mBAAmB;IACnB,kBAAkB;IAClB,gBAAgB;IAChB,iBAAiB;IACjB,mBAAmB;IACnB,kBAAkB;CACV,CAAC;AAEX,MAAM,gBAAgB,GAAG,sBAAsB,CAAC;AAOhD,MAAM,eAAe,GAAG;IACtB,OAAO,EAAE,0BAA0B;IACnC,SAAS,EAAE,6BAA6B;IACxC,UAAU,EAAE,6BAA6B;IACzC,OAAO,EAAE,0BAA0B;IACnC,IAAI,EAAE,uBAAuB;IAC7B,KAAK,EAAE,wBAAwB;CACvB,CAAC;AAQX,uFAAuF;AACvF,MAAM,UAAU,UAAU,CAAC,KAAc;IACvC,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC;SACzB,OAAO,CAAC,IAAI,EAAE,SAAS,CAAC;SACxB,OAAO,CAAC,IAAI,EAAE,SAAS,CAAC;SACxB,OAAO,CAAC,IAAI,EAAE,SAAS,CAAC;SACxB,OAAO,CAAC,SAAS,EAAE,SAAS,CAAC;SAC7B,OAAO,CAAC,SAAS,EAAE,SAAS,CAAC,CAAC;AACnC,CAAC;AAED,SAAS,UAAU,CAAC,KAAa;IAC/B,OAAO,KAAK;SACT,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC;SACvB,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;AAC5B,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,qBAAqB,CACnC,KAAa,EACb,OAAgB,EAChB,cAAiC,EAAE;IAEnC,MAAM,UAAU,GAAG,OAAO,CAAC,CAAC,CAAC,IAAI,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IAChE,uFAAuF;IACvF,4DAA4D;IAC5D,MAAM,WAAW,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,IAAI,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACjF,OAAO;QACL,oBAAoB;QACpB,mBAAmB;QACnB,2BAA2B,KAAK,GAAG;QACnC,uBAAuB,UAAU,EAAE;QACnC,iBAAiB;QACjB,oBAAoB;QACpB,qBAAqB;QACrB,qBAAqB,WAAW,EAAE;QAClC,wBAAwB;QACxB,iBAAiB;KAClB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC;AAQD;;;GAGG;AACH,MAAM,UAAU,aAAa,CAAC,QAA8B;IAC1D,MAAM,YAAY,GAAa,EAAE,CAAC;IAClC,KAAK,MAAM,CAAC,GAAG,EAAE,QAAQ,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,eAAe,CAAC,EAAE,CAAC;QAC9D,MAAM,KAAK,GAAG,QAAQ,EAAE,MAAM,EAAE,CAAC,GAAmC,CAAC,CAAC;QACtE,IAAI,KAAK,EAAE,CAAC;YACV,YAAY,CAAC,IAAI,CAAC,GAAG,QAAQ,IAAI,KAAK,EAAE,CAAC,CAAC;QAC5C,CAAC;IACH,CAAC;IACD,MAAM,OAAO,GAAG,YAAY,CAAC,MAAM,GAAG,CAAC,CAAC;IACxC,IAAI,OAAO,EAAE,CAAC;QACZ,YAAY,CAAC,IAAI,CAAC,oBAAoB,CAAC,CAAC;IAC1C,CAAC;IACD,IAAI,QAAQ,EAAE,UAAU,EAAE,CAAC;QACzB,YAAY,CAAC,IAAI,CAAC,0BAA0B,QAAQ,CAAC,UAAU,EAAE,CAAC,CAAC;IACrE,CAAC;IACD,OAAO,YAAY,CAAC,MAAM,GAAG,CAAC;QAC5B,CAAC,CAAC,EAAE,GAAG,EAAE,cAAc,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,EAAE;QAC3D,CAAC,CAAC,SAAS,CAAC;AAChB,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,OAAe,EAAE,QAA8B;IACzE,OAAO;QACL,OAAO,EAAE,QAAQ,EAAE,OAAO,IAAI,OAAO;QACrC,GAAG,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC5D,CAAC;AACJ,CAAC;AAKD,MAAM,UAAU,WAAW,CACzB,QAAgB,EAChB,QAAwB,EACxB,KAAa,EACb,KAAqB,EACrB,IAAqB;IAErB,MAAM,IAAI,GAAG;QACX,4CAA4C,KAAK,MAAM;QACvD,qBAAqB,UAAU,CAAC,QAAQ,CAAC,OAAO,CAAC,UAAU;QAC3D,wDAAwD,UAAU,CAAC,QAAQ,CAAC,WAAW;QACvF,GAAG,CAAC,IAAI;YACN,CAAC,CAAC,CAAC,sDAAsD,UAAU,CAAC,IAAI,CAAC,WAAW,CAAC;YACrF,CAAC,CAAC,EAAE,CAAC;QACP,GAAG,CAAC,KAAK;YACP,CAAC,CAAC;gBACE,iBAAiB,KAAK,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,wBAAwB,CAAC,CAAC,CAAC,EAAE,IAAI,KAAK,CAAC,GAAG,UAAU;aAC/F;YACH,CAAC,CAAC,EAAE,CAAC;KACR,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IACjB,OAAO,QAAQ,CAAC,OAAO,CAAC,gBAAgB,EAAE,IAAI,CAAC,CAAC;AAClD,CAAC;AAED,SAAS,WAAW,CAAC,KAAa,EAAE,OAAgB,EAAE,WAA+B;IACnF,OAAO;QACL,yBAAyB,EAAE,qBAAqB,CAAC,KAAK,EAAE,OAAO,EAAE,WAAW,CAAC;QAC7E,eAAe,EAAE,UAAU;QAC3B,4BAA4B,EAAE,aAAa;QAC3C,iBAAiB,EAAE,aAAa;QAChC,wBAAwB,EAAE,SAAS;QACnC,iBAAiB,EAAE,MAAM;KAC1B,CAAC;AACJ,CAAC;AAYD,MAAM,UAAU,wBAAwB,CAAC,OAA2B;IAClE,MAAM,QAAQ,GAAG,YAAY,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,YAAY,CAAC,EAAE,MAAM,CAAC,CAAC;IACzE,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,gBAAgB,CAAC,EAAE,CAAC;QACzC,MAAM,IAAI,KAAK,CAAC,GAAG,OAAO,CAAC,KAAK,sBAAsB,gBAAgB,cAAc,CAAC,CAAC;IACxF,CAAC;IACD,OAAO,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,EAAE;QACjC,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,WAAW,CAAC,GAAG,EAAE,CAAC;QAC/C,MAAM,KAAK,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;QACjD,MAAM,QAAQ,GAAG,WAAW,CAAC,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC;QAC9D,OAAO;YACL,IAAI,EAAE,WAAW,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,aAAa,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,IAAI,CAAC;YAClF,OAAO,EAAE,WAAW,CAAC,KAAK,EAAE,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;SAC3D,CAAC;IACJ,CAAC,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,uBAAuB,CACrC,OAA2B,EAC3B,SAA6B,wBAAwB,CAAC,OAAO,CAAC;IAE9D,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAEhC,MAAM,CAAC,GAAG,CACR,SAAS,EACT,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,QAAQ,CAAC,EAAE;QAC5C,WAAW,EAAE,KAAK;QAClB,SAAS,EAAE,IAAI;QACf,MAAM,EAAE,MAAM;QACd,KAAK,EAAE,KAAK;QACZ,UAAU,EAAE,CAAC,GAAG,EAAE,EAAE;YAClB,GAAG,CAAC,SAAS,CAAC,wBAAwB,EAAE,SAAS,CAAC,CAAC;QACrD,CAAC;KACF,CAAC,CACH,CAAC;IAEF,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,iBAAiB,CAAC,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QACrD,MAAM,EAAE;aACL,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE;YAC1B,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC3C,CAAC,CAAC;aACD,KAAK,CAAC,IAAI,CAAC,CAAC;IACjB,CAAC,CAAC,CAAC;IAEH,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { ApiClientAdapter, ApiPayloadAdapter, createApiAdapterFactory } from './adapter.js';
|
|
2
|
+
export { ReplayDetectedError, UnsupportedModelError } from './adapter.js';
|
|
3
|
+
export type { OidcApiClientOptions } from './apiClient.js';
|
|
4
|
+
export { OidcApiClient, SERVICE_TOKEN_SUBJECT } from './apiClient.js';
|
|
5
|
+
export { EdgeConfigCache } from './configCache.js';
|
|
6
|
+
export type { OidcEdge, OidcEdgeOptions } from './createOidcEdge.js';
|
|
7
|
+
export { ADAPTER_COOKIE_NAMES, createOidcEdge } from './createOidcEdge.js';
|
|
8
|
+
export type { Env } from './env.js';
|
|
9
|
+
export { EnvSchema, parseEnv } from './env.js';
|
|
10
|
+
export { OidcApiError, OidcContractMismatchError, OidcProviderDisabledError, OidcSignRefusedError, } from './errors.js';
|
|
11
|
+
export type { FirstPartySession } from './firstPartySession.js';
|
|
12
|
+
export { readFirstPartySession } from './firstPartySession.js';
|
|
13
|
+
export type { InteractionRouterOptions } from './interactions.js';
|
|
14
|
+
export { createInteractionRouter, FIRST_PARTY_CLIENT_PROPERTY } from './interactions.js';
|
|
15
|
+
export { createSameOriginGuard } from './originGuard.js';
|
|
16
|
+
export { buildProviderConfiguration, loadExistingGrant, PROVIDER_COOKIE_NAMES, } from './providerConfig.js';
|
|
17
|
+
export type { EdgeRequestContext } from './requestContext.js';
|
|
18
|
+
export { adoptSession, bindSession, currentRequestContext, runWithRequestContext, } from './requestContext.js';
|
|
19
|
+
export { ApiSigningKey, classifyToken, signingKeysFrom, UnsignableTokenError, } from './signingKey.js';
|
|
20
|
+
export { checkApiCompatibility } from './startup.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright © 2026 Fells Code, LLC
|
|
3
|
+
* Licensed under the Apache License, Version 2.0
|
|
4
|
+
* See LICENSE file in the project root for full license information
|
|
5
|
+
*/
|
|
6
|
+
export { ApiClientAdapter, ApiPayloadAdapter, createApiAdapterFactory } from './adapter.js';
|
|
7
|
+
export { ReplayDetectedError, UnsupportedModelError } from './adapter.js';
|
|
8
|
+
export { OidcApiClient, SERVICE_TOKEN_SUBJECT } from './apiClient.js';
|
|
9
|
+
export { EdgeConfigCache } from './configCache.js';
|
|
10
|
+
export { ADAPTER_COOKIE_NAMES, createOidcEdge } from './createOidcEdge.js';
|
|
11
|
+
export { EnvSchema, parseEnv } from './env.js';
|
|
12
|
+
export { OidcApiError, OidcContractMismatchError, OidcProviderDisabledError, OidcSignRefusedError, } from './errors.js';
|
|
13
|
+
export { readFirstPartySession } from './firstPartySession.js';
|
|
14
|
+
export { createInteractionRouter, FIRST_PARTY_CLIENT_PROPERTY } from './interactions.js';
|
|
15
|
+
export { createSameOriginGuard } from './originGuard.js';
|
|
16
|
+
export { buildProviderConfiguration, loadExistingGrant, PROVIDER_COOKIE_NAMES, } from './providerConfig.js';
|
|
17
|
+
export { adoptSession, bindSession, currentRequestContext, runWithRequestContext, } from './requestContext.js';
|
|
18
|
+
export { ApiSigningKey, classifyToken, signingKeysFrom, UnsignableTokenError, } from './signingKey.js';
|
|
19
|
+
export { checkApiCompatibility } from './startup.js';
|
|
20
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAC;AAC5F,OAAO,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAE1E,OAAO,EAAE,aAAa,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AACtE,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAEnD,OAAO,EAAE,oBAAoB,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAE3E,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AAC/C,OAAO,EACL,YAAY,EACZ,yBAAyB,EACzB,yBAAyB,EACzB,oBAAoB,GACrB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAE/D,OAAO,EAAE,uBAAuB,EAAE,2BAA2B,EAAE,MAAM,mBAAmB,CAAC;AACzF,OAAO,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAC;AACzD,OAAO,EACL,0BAA0B,EAC1B,iBAAiB,EACjB,qBAAqB,GACtB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,YAAY,EACZ,WAAW,EACX,qBAAqB,EACrB,qBAAqB,GACtB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,aAAa,EACb,aAAa,EACb,eAAe,EACf,oBAAoB,GACrB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type Router } from 'express';
|
|
2
|
+
import type Provider from 'oidc-provider';
|
|
3
|
+
import type { OidcApiClient } from './apiClient.js';
|
|
4
|
+
export declare const FIRST_PARTY_CLIENT_PROPERTY = "urn:seamless:first_party";
|
|
5
|
+
export interface InteractionRouterOptions {
|
|
6
|
+
provider: Provider;
|
|
7
|
+
client: OidcApiClient;
|
|
8
|
+
issuerOrigin: string;
|
|
9
|
+
adapterCookieSecret: string;
|
|
10
|
+
accessCookieName: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The JSON routes the hosted pages use to finish an OIDC interaction. They never sign anyone
|
|
14
|
+
* in: the pages do that through this service's first-party adapter routes, and these routes
|
|
15
|
+
* only read the resulting session.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createInteractionRouter(options: InteractionRouterOptions): Router;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright © 2026 Fells Code, LLC
|
|
3
|
+
* Licensed under the Apache License, Version 2.0
|
|
4
|
+
* See LICENSE file in the project root for full license information
|
|
5
|
+
*/
|
|
6
|
+
import cookieParser from 'cookie-parser';
|
|
7
|
+
import express from 'express';
|
|
8
|
+
import { errors } from 'oidc-provider';
|
|
9
|
+
import { authRequirements, stepUpMethods, unmetRequirements } from './authRequirements.js';
|
|
10
|
+
import { readFirstPartySession } from './firstPartySession.js';
|
|
11
|
+
import { createSameOriginGuard } from './originGuard.js';
|
|
12
|
+
import { bindSession } from './requestContext.js';
|
|
13
|
+
import { grantLifetime } from './tokenLifetimes.js';
|
|
14
|
+
export const FIRST_PARTY_CLIENT_PROPERTY = 'urn:seamless:first_party';
|
|
15
|
+
class InteractionRefusal extends Error {
|
|
16
|
+
status;
|
|
17
|
+
error;
|
|
18
|
+
extra;
|
|
19
|
+
constructor(status, error, extra = {}) {
|
|
20
|
+
super(error);
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.error = error;
|
|
23
|
+
this.extra = extra;
|
|
24
|
+
this.name = 'InteractionRefusal';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function stringParam(params, name) {
|
|
28
|
+
const value = params[name];
|
|
29
|
+
return typeof value === 'string' && value ? value : undefined;
|
|
30
|
+
}
|
|
31
|
+
function promptDetails(interaction) {
|
|
32
|
+
return interaction.prompt.details;
|
|
33
|
+
}
|
|
34
|
+
function loginResult(accountId, context) {
|
|
35
|
+
return {
|
|
36
|
+
accountId,
|
|
37
|
+
ts: context.authTime,
|
|
38
|
+
...(context.amr.length > 0 ? { amr: [...context.amr] } : {}),
|
|
39
|
+
...(context.acr ? { acr: context.acr } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The JSON routes the hosted pages use to finish an OIDC interaction. They never sign anyone
|
|
44
|
+
* in: the pages do that through this service's first-party adapter routes, and these routes
|
|
45
|
+
* only read the resulting session.
|
|
46
|
+
*/
|
|
47
|
+
export function createInteractionRouter(options) {
|
|
48
|
+
const { provider, client } = options;
|
|
49
|
+
const router = express.Router();
|
|
50
|
+
router.use('/interaction', cookieParser(), createSameOriginGuard(options.issuerOrigin));
|
|
51
|
+
async function load(req, res) {
|
|
52
|
+
const interaction = await provider.interactionDetails(req, res);
|
|
53
|
+
// The cookie names the browser's current interaction. A page left open from an earlier
|
|
54
|
+
// one must not act on this one.
|
|
55
|
+
if (interaction.uid !== req.params.uid) {
|
|
56
|
+
throw new InteractionRefusal(409, 'interaction_mismatch');
|
|
57
|
+
}
|
|
58
|
+
return interaction;
|
|
59
|
+
}
|
|
60
|
+
async function clientFor(interaction) {
|
|
61
|
+
const clientId = stringParam(interaction.params, 'client_id');
|
|
62
|
+
const found = clientId ? await provider.Client.find(clientId) : undefined;
|
|
63
|
+
if (!found) {
|
|
64
|
+
throw new InteractionRefusal(400, 'invalid_client');
|
|
65
|
+
}
|
|
66
|
+
return found;
|
|
67
|
+
}
|
|
68
|
+
function firstPartySession(req) {
|
|
69
|
+
const cookies = req.cookies;
|
|
70
|
+
const result = readFirstPartySession(cookies[options.accessCookieName], options.adapterCookieSecret);
|
|
71
|
+
if (result.rejection) {
|
|
72
|
+
throw new InteractionRefusal(result.rejection.status, result.rejection.error);
|
|
73
|
+
}
|
|
74
|
+
return result.session;
|
|
75
|
+
}
|
|
76
|
+
router.get('/interaction/:uid/details', handle(async (req, res) => {
|
|
77
|
+
const interaction = await load(req, res);
|
|
78
|
+
const found = await clientFor(interaction);
|
|
79
|
+
const details = promptDetails(interaction);
|
|
80
|
+
const redirectUri = stringParam(interaction.params, 'redirect_uri');
|
|
81
|
+
res.set('cache-control', 'no-store');
|
|
82
|
+
res.json({
|
|
83
|
+
uid: interaction.uid,
|
|
84
|
+
prompt: {
|
|
85
|
+
name: interaction.prompt.name,
|
|
86
|
+
reasons: interaction.prompt.reasons,
|
|
87
|
+
missingScopes: details.missingOIDCScope ?? [],
|
|
88
|
+
missingClaims: details.missingOIDCClaims ?? [],
|
|
89
|
+
missingResourceScopes: Object.entries(details.missingResourceScopes ?? {}).map(([resource, scopes]) => ({ resource, scopes })),
|
|
90
|
+
},
|
|
91
|
+
client: {
|
|
92
|
+
id: found.clientId,
|
|
93
|
+
name: found.clientName ?? found.clientId,
|
|
94
|
+
firstParty: found[FIRST_PARTY_CLIENT_PROPERTY] === true,
|
|
95
|
+
redirectHost: redirectUri ? new URL(redirectUri).host : null,
|
|
96
|
+
},
|
|
97
|
+
scopes: (stringParam(interaction.params, 'scope') ?? '').split(' ').filter(Boolean),
|
|
98
|
+
loginHint: stringParam(interaction.params, 'login_hint') ?? null,
|
|
99
|
+
});
|
|
100
|
+
}));
|
|
101
|
+
router.post('/interaction/:uid/login', express.json({ limit: '1kb' }), handle(async (req, res) => {
|
|
102
|
+
const afterStepUp = typeof req.body === 'object' && req.body !== null && req.body.afterStepUp === true;
|
|
103
|
+
const session = firstPartySession(req);
|
|
104
|
+
const interaction = await load(req, res);
|
|
105
|
+
if (interaction.prompt.name !== 'login') {
|
|
106
|
+
throw new InteractionRefusal(409, 'login_not_requested');
|
|
107
|
+
}
|
|
108
|
+
// Switching accounts would need the provider session ended first, which is
|
|
109
|
+
// RP-initiated logout's job, so the hosted pages sign out of the old one instead.
|
|
110
|
+
if (interaction.session?.accountId && interaction.session.accountId !== session.userId) {
|
|
111
|
+
throw new InteractionRefusal(409, 'account_mismatch');
|
|
112
|
+
}
|
|
113
|
+
const found = await clientFor(interaction);
|
|
114
|
+
bindSession(session.sessionId);
|
|
115
|
+
// The API answers the auth context only for a session holding a grant for the client,
|
|
116
|
+
// so the grant is saved first. It starts empty: the consent step fills it.
|
|
117
|
+
const grant = new provider.Grant({ accountId: session.userId, clientId: found.clientId });
|
|
118
|
+
// Saved outside a provider request, where the ttl.Grant function cannot see the client.
|
|
119
|
+
Object.assign(grant, { expiresIn: grantLifetime(found) });
|
|
120
|
+
const grantId = await grant.save();
|
|
121
|
+
const context = await client.authContext(session.sessionId, found.clientId);
|
|
122
|
+
const refusal = !context || !context.active || context.userId !== session.userId
|
|
123
|
+
? new InteractionRefusal(401, 'login_required')
|
|
124
|
+
: context.recovery
|
|
125
|
+
? new InteractionRefusal(403, 'enrollment_required')
|
|
126
|
+
: undefined;
|
|
127
|
+
if (refusal || !context) {
|
|
128
|
+
// A grant left behind would bind the session to the client without a sign-in.
|
|
129
|
+
await grant.destroy();
|
|
130
|
+
throw refusal ?? new InteractionRefusal(401, 'login_required');
|
|
131
|
+
}
|
|
132
|
+
const unmet = unmetRequirements(authRequirements(interaction.params), context, interaction.iat);
|
|
133
|
+
if (unmet) {
|
|
134
|
+
await grant.destroy();
|
|
135
|
+
if (!afterStepUp) {
|
|
136
|
+
throw new InteractionRefusal(409, 'step_up_required', {
|
|
137
|
+
methods: stepUpMethods(unmet, context),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
// A weaker step-up than the request needs (a synced passkey for phrh, say) ends the
|
|
141
|
+
// request instead of issuing a lower acr.
|
|
142
|
+
const redirectTo = await provider.interactionResult(req, res, {
|
|
143
|
+
error: 'unmet_authentication_requirements',
|
|
144
|
+
error_description: 'the requested authentication could not be obtained',
|
|
145
|
+
}, { mergeWithLastSubmission: false });
|
|
146
|
+
res.json({ redirectTo });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const redirectTo = await provider.interactionResult(req, res, {
|
|
150
|
+
login: loginResult(session.userId, context),
|
|
151
|
+
consent: { grantId },
|
|
152
|
+
});
|
|
153
|
+
res.json({ redirectTo });
|
|
154
|
+
}));
|
|
155
|
+
router.post('/interaction/:uid/consent', handle(async (req, res) => {
|
|
156
|
+
const session = firstPartySession(req);
|
|
157
|
+
const interaction = await load(req, res);
|
|
158
|
+
if (interaction.prompt.name !== 'consent') {
|
|
159
|
+
throw new InteractionRefusal(409, 'consent_not_requested');
|
|
160
|
+
}
|
|
161
|
+
const accountId = interaction.session?.accountId;
|
|
162
|
+
if (!accountId || accountId !== session.userId) {
|
|
163
|
+
throw new InteractionRefusal(409, 'account_mismatch');
|
|
164
|
+
}
|
|
165
|
+
const found = await clientFor(interaction);
|
|
166
|
+
const existing = interaction.grantId
|
|
167
|
+
? await provider.Grant.find(interaction.grantId)
|
|
168
|
+
: undefined;
|
|
169
|
+
const grant = existing && existing.accountId === accountId && existing.clientId === found.clientId
|
|
170
|
+
? existing
|
|
171
|
+
: new provider.Grant({ accountId, clientId: found.clientId });
|
|
172
|
+
const details = promptDetails(interaction);
|
|
173
|
+
if (details.missingOIDCScope?.length) {
|
|
174
|
+
grant.addOIDCScope(details.missingOIDCScope.join(' '));
|
|
175
|
+
}
|
|
176
|
+
if (details.missingOIDCClaims?.length) {
|
|
177
|
+
grant.addOIDCClaims(details.missingOIDCClaims);
|
|
178
|
+
}
|
|
179
|
+
for (const [indicator, scopes] of Object.entries(details.missingResourceScopes ?? {})) {
|
|
180
|
+
grant.addResourceScope(indicator, scopes.join(' '));
|
|
181
|
+
}
|
|
182
|
+
Object.assign(grant, { expiresIn: grantLifetime(found) });
|
|
183
|
+
const grantId = await grant.save();
|
|
184
|
+
const redirectTo = await provider.interactionResult(req, res, { consent: { grantId } }, { mergeWithLastSubmission: true });
|
|
185
|
+
res.json({ redirectTo });
|
|
186
|
+
}));
|
|
187
|
+
router.post('/interaction/:uid/abort', handle(async (req, res) => {
|
|
188
|
+
await load(req, res);
|
|
189
|
+
const redirectTo = await provider.interactionResult(req, res, { error: 'access_denied', error_description: 'End-User aborted interaction' }, { mergeWithLastSubmission: false });
|
|
190
|
+
res.json({ redirectTo });
|
|
191
|
+
}));
|
|
192
|
+
return router;
|
|
193
|
+
}
|
|
194
|
+
function handle(fn) {
|
|
195
|
+
return (req, res, next) => {
|
|
196
|
+
fn(req, res).catch((error) => {
|
|
197
|
+
if (error instanceof InteractionRefusal) {
|
|
198
|
+
res.status(error.status).json({ ...error.extra, error: error.error });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (error instanceof errors.SessionNotFound) {
|
|
202
|
+
res.status(404).json({ error: 'interaction_expired' });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
next(error);
|
|
206
|
+
});
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
//# sourceMappingURL=interactions.js.map
|