@owlmeans/server-oidc-provider 0.1.18-rc.41 → 0.1.18-rc.43

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/README.md CHANGED
@@ -14,7 +14,7 @@ Embedded OIDC identity provider — wraps the `oidc-provider` library as an OwlM
14
14
  ## Installation
15
15
 
16
16
  ```bash
17
- bun add @owlmeans/server-oidc-provider@^0.1.18-rc.41
17
+ bun add @owlmeans/server-oidc-provider@^0.1.18-rc.43
18
18
  ```
19
19
 
20
20
  ## Usage
@@ -72,7 +72,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
72
72
  your project's skill store (`.agents/skills/`):
73
73
 
74
74
  ```sh
75
- npx @owlmeans/agent-skills@^0.1.18-rc.39
75
+ npx @owlmeans/agent-skills@^0.1.18-rc.41
76
76
  ```
77
77
 
78
78
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/server-oidc-provider",
4
- "version": "0.1.18-rc.41",
5
- "generatedAt": "2026-09-24T14:00:35.072Z",
4
+ "version": "0.1.18-rc.43",
5
+ "generatedAt": "2026-09-28T02:06:37.889Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -8,7 +8,7 @@ user-invocable: false
8
8
  # @owlmeans/server-oidc-provider
9
9
 
10
10
  **Layer:** Server
11
- **Install:** `"@owlmeans/server-oidc-provider": "^0.1.18-rc.41"` in `dependencies`
11
+ **Install:** `"@owlmeans/server-oidc-provider": "^0.1.18-rc.43"` in `dependencies`
12
12
  **Runtime deps:** `oidc-provider@9.11.1` (exact), `jose@6.2.5` (exact), `@types/oidc-provider@9.5.0`
13
13
 
14
14
  Use this only when your service **is** the identity provider. For consuming someone else's issuer,
@@ -102,6 +102,25 @@ const key = await jose.importPKCS8(pkcs8Pem, 'RS256', { extractable: true })
102
102
  const jwk = await jose.exportJWK(key)
103
103
  ```
104
104
 
105
+ ## A refused account is a login, not a crash
106
+
107
+ `loadById` answering `undefined` refuses an account (another organization's subject, a disabled or
108
+ expired profile). The provider does not treat that as "not logged in": its `no_session` check reads
109
+ the raw `session.accountId`, so the login prompt is skipped, `loadGrant` builds no grant for an
110
+ unloaded account, and the consent prompt throws `TypeError … oidc.grant.getOIDCScopeEncountered` — the
111
+ relying party gets `server_error` / "oops! something went wrong".
112
+
113
+ The service therefore installs `makeInteractionPolicy()` (`src/utils/policy.ts`) as
114
+ `interactions.policy`: the provider's default policy plus an `account_refused` check on the login
115
+ prompt that requests a login whenever the session names an account the provider could not load. After
116
+ that login the provider's resume step signs the stale session out and continues the interrupted
117
+ request. A login that has just completed and is refused again (`oidc.result.login`) throws
118
+ `access_denied` instead of prompting, so a refusal can never loop. A consumer's own
119
+ `customConfiguration.interactions` is not merged — the service sets `policy` and `url` itself.
120
+ `tests/policy.spec.ts` drives a real provider through the whole sequence, including the upstream
121
+ crash as a canary: when a bump of `oidc-provider` makes that first test fail, the upstream fixed it
122
+ and the check may go.
123
+
105
124
  ## Response headers the hardened defaults get wrong
106
125
 
107
126
  `@fastify/helmet` runs with its defaults on every OwlMeans server, and two of them break an
@@ -1 +1 @@
1
- {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,OAAO,EAA0C,mBAAmB,EAAE,MAAM,YAAY,CAAA;AAS9G,eAAO,MAAM,yBAAyB,WAAW,MAAM,KAAmB,mBAoHzE,CAAA;AAED,eAAO,MAAM,yBAAyB,GAAI,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,OAAO,CAAC,CAAC,CAAC,OACzE,CAAC,UAAS,MAAM,KACpB,CAOF,CAAA"}
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,OAAO,EAA0C,mBAAmB,EAAE,MAAM,YAAY,CAAA;AAU9G,eAAO,MAAM,yBAAyB,WAAW,MAAM,KAAmB,mBAqHzE,CAAA;AAED,eAAO,MAAM,yBAAyB,GAAI,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,OAAO,CAAC,CAAC,CAAC,OACzE,CAAC,UAAS,MAAM,KACpB,CAOF,CAAA"}
package/build/service.js CHANGED
@@ -5,6 +5,7 @@ import Provider from 'oidc-provider';
5
5
  import { PARAM, SEP } from '@owlmeans/route';
6
6
  import { makeSecurityHelper } from '@owlmeans/config';
7
7
  import { combineConfig } from './utils/config.js';
8
+ import { makeInteractionPolicy } from './utils/policy.js';
8
9
  let _initializedOidc = undefined;
9
10
  export const createOidcProviderService = (alias = DEFAULT_ALIAS) => {
10
11
  const service = createService(alias, {
@@ -27,6 +28,7 @@ export const createOidcProviderService = (alias = DEFAULT_ALIAS) => {
27
28
  return accountSrv.loadById(context, id, { clientId });
28
29
  },
29
30
  interactions: {
31
+ policy: makeInteractionPolicy(),
30
32
  url: async (_, interaction) => {
31
33
  // The interaction screen is a FRONTEND route, and this is a server context — the
32
34
  // entrypoint registered here has no `url()` (that helper is attached by
@@ -1 +1 @@
1
- {"version":3,"file":"service.js","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"service.js","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAChE,OAAO,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAA;AACjE,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AAG3E,OAAO,QAAQ,MAAM,eAAe,CAAA;AAGpC,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,MAAM,iBAAiB,CAAA;AAC5C,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAA;AACrD,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AACjD,OAAO,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAA;AAEzD,IAAI,gBAAgB,GAAyB,SAAS,CAAA;AACtD,MAAM,CAAC,MAAM,yBAAyB,GAAG,CAAC,KAAK,GAAW,aAAa,EAAuB,EAAE;IAC9F,MAAM,OAAO,GAAwB,aAAa,CAAsB,KAAK,EAAE;QAC7E,MAAM,EAAE,KAAK,EAAC,GAAG,EAAC,EAAE;YAClB,MAAM,OAAO,GAAG,aAAa,CAAkB,OAAO,CAAC,GAAc,EAAE,KAAK,CAAC,CAAA;YAC7E,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAA;YAE5B,MAAM,YAAY,GAAG,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,CAAC,WAAW,IAAI,OAAO,CAAC,GAAG,CAAC,OAAO,CAAe,CAAA;YAC/F,MAAM,MAAM,GAAG,kBAAkB,CAAkB,OAAO,CAAC,CAAA;YAC3D,MAAM,GAAG,GAAG,MAAM,CAAC,OAAO,CAAC,YAAY,EAAE,GAAG,CAAC,QAAQ,IAAI,YAAY,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAA;YACtF,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,QAAQ,EAAE,QAAQ,KAAK,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,UAAU,CAAC,OAAO,CAAC,CAAA;YAE5F,MAAM,IAAI,GAAG,IAAI,QAAQ,CAAC,GAAG,EAAE;gBAC7B,GAAG,MAAM,aAAa,CAAC,OAAO,EAAE,QAAQ,CAAC;gBAEzC,OAAO,EAAE,GAAG,CAAC,cAAc,IAAI,IAAI;oBACjC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,CAAC,OAAO,CAAqB,GAAG,CAAC,cAAe,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC;oBACjF,CAAC,CAAC,SAAS;gBAEb,WAAW,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,EAAE,KAAK,EAAE,EAAE;oBACrC,MAAM,UAAU,GAAG,OAAO,CAAC,OAAO,CAChC,GAAG,CAAC,cAAc,IAAI,oBAAoB,CAC3C,CAAA;oBAED,MAAM,QAAQ,GAAI,IAAsD,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ;2BAC1F,KAA2C,EAAE,QAAQ,CAAA;oBAE3D,OAAO,UAAU,CAAC,QAAQ,CAAC,OAAO,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,CAAC,CAAA;gBACvD,CAAC;gBAED,YAAY,EAAE;oBACZ,MAAM,EAAE,qBAAqB,EAAE;oBAC/B,GAAG,EAAE,KAAK,EAAE,CAAC,EAAE,WAAW,EAAE,EAAE;wBAC5B,iFAAiF;wBACjF,wEAAwE;wBACxE,uFAAuF;wBACvF,uFAAuF;wBACvF,sFAAsF;wBACtF,cAAc;wBACd,MAAM,KAAK,GAAG,OAAO,CAAC,UAAU,CAAmB,WAAW,CAAC,CAAA;wBAC/D,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC;6BACjC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,KAAK,GAAG,KAAK,GAAG,eAAe,EAAE,CAAC,CAAC,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC;6BAC3E,IAAI,CAAC,GAAG,CAAC,CAAA;wBAEZ,OAAO,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,EAAE,EAAE,IAAI,CAAC,CAAA;oBAC9C,CAAC;iBACF;aACF,CAAC,CAAA;YAEF,IAAI,CAAC,KAAK,GAAG,GAAG,CAAC,WAAW,IAAI,QAAQ,CAAA;YACxC,MAAM,IAAI,GAAG,GAAG,GAAG,CAAC,GAAG,CAAC,QAAQ,IAAI,YAAY,CAAC,CAAA;YAEjD;;;;;;;;;;eAUG;YACH,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,IAAa,EAAE,GAAmB,EAAE,IAAgB,EAAE,EAAE;gBAC5E,mFAAmF;gBACnF,uFAAuF;gBACvF,yFAAyF;gBACzF,oFAAoF;gBACpF,6DAA6D;gBAC7D,GAAG,CAAC,SAAS,CAAC,4BAA4B,EAAE,aAAa,CAAC,CAAA;gBAE1D,oFAAoF;gBACpF,8BAA8B;gBAC9B,MAAM,GAAG,GAAG,GAAG,CAAC,SAAS,CAAC,yBAAyB,CAAC,CAAA;gBACpD,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,CAAC,QAAQ,CAAC,oBAAoB,CAAC,EAAE,CAAC;oBAClE,GAAG,CAAC,SAAS,CAAC,yBAAyB,EAAE,GAAG,CAAC,OAAO,CAAC,oBAAoB,EAAE,eAAe,CAAC,CAAC,CAAA;gBAC9F,CAAC;gBAED,IAAI,EAAE,CAAA;YACR,CAAC,CAAC,CAAA;YAEF,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAA;YAErC,IAAI,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,IAAI,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC;gBAEtD,IAAI,CAAC,EAAE,CAAC,aAAa,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,EAAE;oBAClC,OAAO,CAAC,IAAI,CAAC,uBAAuB,CAAC,CAAA;oBACrC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;oBACzB,OAAO,CAAC,KAAK,CAAC,oBAAoB,EAAE,KAAK,CAAC,CAAA;gBAC5C,CAAC,CAAC,CAAA;gBAEF,IAAI,CAAC,EAAE,CAAC,cAAc,EAAE,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE;oBACrC,OAAO,CAAC,IAAI,CAAC,wBAAwB,EAAE,MAAM,CAAC,mBAAmB,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAA;oBAC5E,OAAO,CAAC,IAAI,CAAE,GAAG,CAAC,IAAY,CAAC,KAAK,CAAC,CAAA;oBACrC,OAAO,CAAC,KAAK,CAAC,qBAAqB,EAAE,KAAK,CAAC,CAAA;gBAC7C,CAAC,CAAC,CAAA;gBAEF,IAAI,CAAC,EAAE,CAAC,gBAAgB,EAAE,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE;oBACvC,OAAO,CAAC,IAAI,CAAC,2BAA2B,EAAE,MAAM,CAAC,mBAAmB,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAA;oBAC/E,OAAO,CAAC,IAAI,CAAE,GAAG,CAAC,IAAY,CAAC,KAAK,CAAC,CAAA;oBACrC,OAAO,CAAC,KAAK,CAAC,wBAAwB,EAAE,KAAK,CAAC,CAAA;gBAChD,CAAC,CAAC,CAAA;YAEJ,CAAC;YAED,gBAAgB,GAAG,OAAO,CAAC,IAAI,GAAG,IAAI,CAAA;QACxC,CAAC;QAED,QAAQ,EAAE,GAAG,EAAE;YACb,OAAO,OAAO,CAAC,IAAI,IAAI,CAAC,OAAO,CAAC,IAAI,GAAG,gBAAiB,CAAC,CAAA;QAC3D,CAAC;QAED,cAAc,EAAE,KAAK,EAAC,EAAE,EAAC,EAAE;YACzB,OAAO,MAAM,OAAO,CAAC,QAAQ,EAAE,CAAC,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,IAAI,CAAA;QAC9D,CAAC;KACF,CAAC,CAAA;IAEF,OAAO,OAAO,CAAA;AAChB,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,yBAAyB,GAAG,CACvC,GAAM,EAAE,KAAK,GAAW,aAAa,EAClC,EAAE;IACL,MAAM,OAAO,GAAG,yBAAyB,CAAC,KAAK,CAAC,CAAA;IAChD,MAAM,OAAO,GAAG,GAAQ,CAAA;IAExB,OAAO,CAAC,eAAe,CAAC,OAAO,CAAC,CAAA;IAEhC,OAAO,OAAO,CAAA;AAChB,CAAC,CAAA"}
@@ -0,0 +1,26 @@
1
+ import { interactionPolicy } from 'oidc-provider';
2
+ /** The `reason` of the login check this module adds to the provider's default policy. */
3
+ export declare const ACCOUNT_REFUSED_REASON = "account_refused";
4
+ /**
5
+ * The provider's default interaction policy plus one login check: a session whose account the
6
+ * application REFUSED to load is a session that identifies nobody, so the person logs in again.
7
+ *
8
+ * `findAccount` answering `undefined` is the seam's way of refusing (`OidcAccountService.loadById`),
9
+ * but the provider does not treat it as an absent login. Its own `no_session` check reads the raw
10
+ * `session.accountId`, which is still set, so the login prompt is skipped; `loadGrant` builds a
11
+ * grant only for a loaded account, so the consent prompt then dereferences a grant that was never
12
+ * created and the request ends as a `server_error` — "oops! something went wrong" on the relying
13
+ * party's callback, with a `TypeError` as its only trace. A refusal is routine (a subject that
14
+ * belongs to another organization, a disabled or expired profile), so it has to end in the login
15
+ * prompt, never in a crash.
16
+ *
17
+ * On the way back the provider's own resume step swaps the accounts: a login result naming an
18
+ * account other than the session's runs its logout confirmation first, dropping the stale session,
19
+ * and then continues the original request.
20
+ *
21
+ * A login that has JUST completed (`oidc.result.login`) and still names an account the application
22
+ * refuses is not asked again — that would loop between the login and the refusal for good — it is
23
+ * an `access_denied` the relying party can show.
24
+ */
25
+ export declare const makeInteractionPolicy: () => interactionPolicy.DefaultPolicy;
26
+ //# sourceMappingURL=policy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../../src/utils/policy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,iBAAiB,EAAE,MAAM,eAAe,CAAA;AAEzD,yFAAyF;AACzF,eAAO,MAAM,sBAAsB,oBAAoB,CAAA;AAEvD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,qBAAqB,QAAO,iBAAiB,CAAC,aAqB1D,CAAA"}
@@ -0,0 +1,38 @@
1
+ import { errors, interactionPolicy } from 'oidc-provider';
2
+ /** The `reason` of the login check this module adds to the provider's default policy. */
3
+ export const ACCOUNT_REFUSED_REASON = 'account_refused';
4
+ /**
5
+ * The provider's default interaction policy plus one login check: a session whose account the
6
+ * application REFUSED to load is a session that identifies nobody, so the person logs in again.
7
+ *
8
+ * `findAccount` answering `undefined` is the seam's way of refusing (`OidcAccountService.loadById`),
9
+ * but the provider does not treat it as an absent login. Its own `no_session` check reads the raw
10
+ * `session.accountId`, which is still set, so the login prompt is skipped; `loadGrant` builds a
11
+ * grant only for a loaded account, so the consent prompt then dereferences a grant that was never
12
+ * created and the request ends as a `server_error` — "oops! something went wrong" on the relying
13
+ * party's callback, with a `TypeError` as its only trace. A refusal is routine (a subject that
14
+ * belongs to another organization, a disabled or expired profile), so it has to end in the login
15
+ * prompt, never in a crash.
16
+ *
17
+ * On the way back the provider's own resume step swaps the accounts: a login result naming an
18
+ * account other than the session's runs its logout confirmation first, dropping the stale session,
19
+ * and then continues the original request.
20
+ *
21
+ * A login that has JUST completed (`oidc.result.login`) and still names an account the application
22
+ * refuses is not asked again — that would loop between the login and the refusal for good — it is
23
+ * an `access_denied` the relying party can show.
24
+ */
25
+ export const makeInteractionPolicy = () => {
26
+ const policy = interactionPolicy.base();
27
+ policy.get('login')?.checks.add(new interactionPolicy.Check(ACCOUNT_REFUSED_REASON, 'End-User account is not accepted, authentication is required', 'login_required', ({ oidc }) => {
28
+ if (oidc.session?.accountId == null || oidc.account != null) {
29
+ return interactionPolicy.Check.NO_NEED_TO_PROMPT;
30
+ }
31
+ if (oidc.result?.login != null) {
32
+ throw new errors.AccessDenied(undefined, 'the account that signed in is not accepted');
33
+ }
34
+ return interactionPolicy.Check.REQUEST_PROMPT;
35
+ }));
36
+ return policy;
37
+ };
38
+ //# sourceMappingURL=policy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policy.js","sourceRoot":"","sources":["../../src/utils/policy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAA;AAEzD,yFAAyF;AACzF,MAAM,CAAC,MAAM,sBAAsB,GAAG,iBAAiB,CAAA;AAEvD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,GAAoC,EAAE;IACzE,MAAM,MAAM,GAAG,iBAAiB,CAAC,IAAI,EAAE,CAAA;IAEvC,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,GAAG,CAAC,IAAI,iBAAiB,CAAC,KAAK,CACzD,sBAAsB,EACtB,8DAA8D,EAC9D,gBAAgB,EAChB,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE;QACX,IAAI,IAAI,CAAC,OAAO,EAAE,SAAS,IAAI,IAAI,IAAI,IAAI,CAAC,OAAO,IAAI,IAAI,EAAE,CAAC;YAC5D,OAAO,iBAAiB,CAAC,KAAK,CAAC,iBAAiB,CAAA;QAClD,CAAC;QAED,IAAI,IAAI,CAAC,MAAM,EAAE,KAAK,IAAI,IAAI,EAAE,CAAC;YAC/B,MAAM,IAAI,MAAM,CAAC,YAAY,CAAC,SAAS,EAAE,4CAA4C,CAAC,CAAA;QACxF,CAAC;QAED,OAAO,iBAAiB,CAAC,KAAK,CAAC,cAAc,CAAA;IAC/C,CAAC,CACF,CAAC,CAAA;IAEF,OAAO,MAAM,CAAA;AACf,CAAC,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/server-oidc-provider",
3
- "version": "0.1.18-rc.41",
3
+ "version": "0.1.18-rc.43",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -38,9 +38,9 @@
38
38
  "@owlmeans/config": "^0.1.18-rc.38",
39
39
  "@owlmeans/context": "^0.1.18-rc.32",
40
40
  "@owlmeans/entrypoint": "^0.1.18-rc.37",
41
- "@owlmeans/oidc": "^0.1.18-rc.46",
41
+ "@owlmeans/oidc": "^0.1.18-rc.48",
42
42
  "@owlmeans/route": "^0.1.18-rc.33",
43
- "@owlmeans/server-api": "^0.1.18-rc.42",
43
+ "@owlmeans/server-api": "^0.1.18-rc.44",
44
44
  "@owlmeans/server-context": "^0.1.18-rc.37",
45
45
  "@scure/base": "^2.3.0",
46
46
  "jose": "6.2.5",
package/src/service.ts CHANGED
@@ -9,6 +9,7 @@ import type { CommonEntrypoint } from '@owlmeans/entrypoint'
9
9
  import { PARAM, SEP } from '@owlmeans/route'
10
10
  import { makeSecurityHelper } from '@owlmeans/config'
11
11
  import { combineConfig } from './utils/config.js'
12
+ import { makeInteractionPolicy } from './utils/policy.js'
12
13
 
13
14
  let _initializedOidc: Provider | undefined = undefined
14
15
  export const createOidcProviderService = (alias: string = DEFAULT_ALIAS): OidcProviderService => {
@@ -41,6 +42,7 @@ export const createOidcProviderService = (alias: string = DEFAULT_ALIAS): OidcPr
41
42
  },
42
43
 
43
44
  interactions: {
45
+ policy: makeInteractionPolicy(),
44
46
  url: async (_, interaction) => {
45
47
  // The interaction screen is a FRONTEND route, and this is a server context — the
46
48
  // entrypoint registered here has no `url()` (that helper is attached by
@@ -0,0 +1,48 @@
1
+ import { errors, interactionPolicy } from 'oidc-provider'
2
+
3
+ /** The `reason` of the login check this module adds to the provider's default policy. */
4
+ export const ACCOUNT_REFUSED_REASON = 'account_refused'
5
+
6
+ /**
7
+ * The provider's default interaction policy plus one login check: a session whose account the
8
+ * application REFUSED to load is a session that identifies nobody, so the person logs in again.
9
+ *
10
+ * `findAccount` answering `undefined` is the seam's way of refusing (`OidcAccountService.loadById`),
11
+ * but the provider does not treat it as an absent login. Its own `no_session` check reads the raw
12
+ * `session.accountId`, which is still set, so the login prompt is skipped; `loadGrant` builds a
13
+ * grant only for a loaded account, so the consent prompt then dereferences a grant that was never
14
+ * created and the request ends as a `server_error` — "oops! something went wrong" on the relying
15
+ * party's callback, with a `TypeError` as its only trace. A refusal is routine (a subject that
16
+ * belongs to another organization, a disabled or expired profile), so it has to end in the login
17
+ * prompt, never in a crash.
18
+ *
19
+ * On the way back the provider's own resume step swaps the accounts: a login result naming an
20
+ * account other than the session's runs its logout confirmation first, dropping the stale session,
21
+ * and then continues the original request.
22
+ *
23
+ * A login that has JUST completed (`oidc.result.login`) and still names an account the application
24
+ * refuses is not asked again — that would loop between the login and the refusal for good — it is
25
+ * an `access_denied` the relying party can show.
26
+ */
27
+ export const makeInteractionPolicy = (): interactionPolicy.DefaultPolicy => {
28
+ const policy = interactionPolicy.base()
29
+
30
+ policy.get('login')?.checks.add(new interactionPolicy.Check(
31
+ ACCOUNT_REFUSED_REASON,
32
+ 'End-User account is not accepted, authentication is required',
33
+ 'login_required',
34
+ ({ oidc }) => {
35
+ if (oidc.session?.accountId == null || oidc.account != null) {
36
+ return interactionPolicy.Check.NO_NEED_TO_PROMPT
37
+ }
38
+
39
+ if (oidc.result?.login != null) {
40
+ throw new errors.AccessDenied(undefined, 'the account that signed in is not accepted')
41
+ }
42
+
43
+ return interactionPolicy.Check.REQUEST_PROMPT
44
+ }
45
+ ))
46
+
47
+ return policy
48
+ }
@@ -0,0 +1,216 @@
1
+ import { describe, it, expect, afterEach } from 'bun:test'
2
+ import { createServer } from 'node:http'
3
+ import type { Server } from 'node:http'
4
+ import type { AddressInfo } from 'node:net'
5
+ import { createHmac } from 'node:crypto'
6
+ import Provider, { errors, interactionPolicy } from 'oidc-provider'
7
+ import type { Configuration } from 'oidc-provider'
8
+ import { ACCOUNT_REFUSED_REASON, makeInteractionPolicy } from '../src/utils/policy.js'
9
+
10
+ const KEYS = ['policy-spec-cookie-key']
11
+ const CLIENT_ID = 'policy-spec-client'
12
+ const REDIRECT_URI = 'https://rp.example.test/callback'
13
+ const STRANGER = 'email-otp:someone-elses'
14
+ const MEMBER = 'email-otp:this-organization'
15
+
16
+ interface Running { provider: Provider, base: string }
17
+
18
+ const running: Server[] = []
19
+
20
+ afterEach(() => {
21
+ while (running.length > 0) running.pop()?.close()
22
+ })
23
+
24
+ /** A real provider served on a loopback port, whose `findAccount` refuses `STRANGER` alone. */
25
+ const start = async (policy: Configuration['interactions']): Promise<Running> => {
26
+ const server = createServer()
27
+ await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
28
+ running.push(server)
29
+ const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
30
+
31
+ const provider = new Provider(base, {
32
+ clients: [{
33
+ client_id: CLIENT_ID,
34
+ client_secret: 'policy-spec-secret',
35
+ redirect_uris: [REDIRECT_URI],
36
+ response_types: ['code'],
37
+ grant_types: ['authorization_code'],
38
+ }],
39
+ cookies: { keys: KEYS },
40
+ features: { devInteractions: { enabled: false } },
41
+ findAccount: async (_, id) => id === STRANGER
42
+ ? undefined
43
+ : { accountId: id, claims: async () => ({ sub: id }) },
44
+ interactions: {
45
+ ...policy,
46
+ url: async (_, interaction) => `${base}/login/${interaction.uid}`,
47
+ },
48
+ })
49
+
50
+ server.on('request', provider.callback())
51
+
52
+ return { provider, base }
53
+ }
54
+
55
+ /** Koa's signed-cookie signature: the first key's HMAC-SHA1, base64 with the URL-safe alphabet. */
56
+ const sign = (pair: string): string => createHmac('sha1', KEYS[0]).update(pair).digest('base64')
57
+ .replace(/[/+=]/g, c => ({ '/': '_', '+': '-', '=': '' })[c] as string)
58
+
59
+ /** A provider session that already names `accountId`, as a browser cookie header. */
60
+ const sessionCookie = async (provider: Provider, accountId: string): Promise<string> => {
61
+ const session = Object.assign(new provider.Session(), { accountId })
62
+ await session.save(600)
63
+ const pair = `_session=${session.jti}`
64
+
65
+ return `${pair}; _session.sig=${sign(pair)}`
66
+ }
67
+
68
+ const AUTHORIZATION = new URLSearchParams({
69
+ client_id: CLIENT_ID,
70
+ redirect_uri: REDIRECT_URI,
71
+ response_type: 'code',
72
+ scope: 'openid',
73
+ })
74
+
75
+ // The provider is asked for a redirect, never followed: the interesting part is where it points.
76
+ const authorize = async ({ base }: Running, cookie: string): Promise<Response> =>
77
+ fetch(`${base}/auth?${AUTHORIZATION}`, { redirect: 'manual', headers: { cookie } })
78
+
79
+ /** The cookies a browser would hold between requests to the provider. */
80
+ const makeJar = (initial = '') => {
81
+ const jar = new Map<string, string>(
82
+ initial === '' ? [] : initial.split('; ').map(pair => [pair.slice(0, pair.indexOf('=')), pair.slice(pair.indexOf('=') + 1)])
83
+ )
84
+
85
+ return {
86
+ header: () => [...jar].map(([name, value]) => `${name}=${value}`).join('; '),
87
+ remember: (response: Response) => {
88
+ for (const line of response.headers.getSetCookie()) {
89
+ const [pair] = line.split(';')
90
+ const name = pair.slice(0, pair.indexOf('='))
91
+ const value = pair.slice(pair.indexOf('=') + 1)
92
+ if (value === '') jar.delete(name)
93
+ else jar.set(name, value)
94
+ }
95
+ },
96
+ }
97
+ }
98
+
99
+ const locationOf = (response: Response): URL => new URL(response.headers.get('location') ?? 'about:blank')
100
+
101
+ /** The interaction a redirect to the (test) login screen points at. */
102
+ const interactionOf = (env: Running, response: Response) =>
103
+ env.provider.Interaction.find(locationOf(response).pathname.split('/').pop() ?? '')
104
+
105
+
106
+ describe('login policy for a session whose account the application refused', () => {
107
+ it('crashes the authorization request with the provider default policy', async () => {
108
+ const env = await start(undefined)
109
+ const response = await authorize(env, await sessionCookie(env.provider, STRANGER))
110
+
111
+ // The provider's own answer to the crash: an error page, never a login. (Behind the
112
+ // application's error rendering the relying party receives `error=server_error`.)
113
+ expect(response.status).toBe(500)
114
+ expect(await response.text()).toContain('server_error')
115
+ })
116
+
117
+ it('sends it back to the login interaction', async () => {
118
+ const env = await start({ policy: makeInteractionPolicy() })
119
+ const response = await authorize(env, await sessionCookie(env.provider, STRANGER))
120
+
121
+ expect(response.status).toBe(303)
122
+ expect(locationOf(response).href.startsWith(`${env.base}/login/`)).toBe(true)
123
+
124
+ const interaction = await interactionOf(env, response)
125
+ expect(interaction?.prompt.name).toBe('login')
126
+ expect(interaction?.prompt.reasons).toContain(ACCOUNT_REFUSED_REASON)
127
+ })
128
+
129
+ it('still asks a visitor with no session to log in', async () => {
130
+ const env = await start({ policy: makeInteractionPolicy() })
131
+ const response = await authorize(env, '')
132
+
133
+ expect(response.status).toBe(303)
134
+ const interaction = await interactionOf(env, response)
135
+ expect(interaction?.prompt.name).toBe('login')
136
+ expect(interaction?.prompt.reasons).not.toContain(ACCOUNT_REFUSED_REASON)
137
+ })
138
+ })
139
+
140
+ describe('the login that follows a refused session', () => {
141
+ it('replaces the stale session and completes the original request', async () => {
142
+ const env = await start({ policy: makeInteractionPolicy() })
143
+ const jar = makeJar(await sessionCookie(env.provider, STRANGER))
144
+ const step = async (url: string, init: RequestInit = {}): Promise<Response> => {
145
+ const response = await fetch(url, {
146
+ ...init, redirect: 'manual', headers: { ...init.headers, cookie: jar.header() },
147
+ })
148
+ jar.remember(response)
149
+
150
+ return response
151
+ }
152
+
153
+ // 1. The refused session is asked to log in.
154
+ const asked = await step(`${env.base}/auth?${AUTHORIZATION}`)
155
+ const interaction = await interactionOf(env, asked)
156
+ expect(interaction?.prompt.name).toBe('login')
157
+
158
+ // 2. The login screen finishes as the application's interaction handler does.
159
+ const grant = new env.provider.Grant({ accountId: MEMBER, clientId: CLIENT_ID })
160
+ grant.addOIDCScope('openid')
161
+ interaction!.result = { login: { accountId: MEMBER }, consent: { grantId: await grant.save() } }
162
+ await interaction!.save(600)
163
+
164
+ // 3. Resuming names an account other than the session's, so the provider signs the old one out…
165
+ const resumed = await step(interaction!.returnTo)
166
+ expect(resumed.status).toBe(200)
167
+ const form = await resumed.text()
168
+ const action = /action="([^"]+)"/.exec(form)?.[1]
169
+ const xsrf = /name="xsrf" value="([^"]+)"/.exec(form)?.[1]
170
+ expect(action).toContain('/session/end/confirm')
171
+ expect(xsrf).toBeDefined()
172
+
173
+ const signedOut = await step(action!, {
174
+ method: 'POST',
175
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
176
+ body: new URLSearchParams({ xsrf: xsrf!, logout: 'yes' }),
177
+ })
178
+ expect(signedOut.status).toBe(303)
179
+
180
+ // 4. …and continues the request it interrupted, ending at the relying party with a code.
181
+ const done = await step(signedOut.headers.get('location')!)
182
+ expect(done.status).toBe(303)
183
+ const callback = locationOf(done)
184
+ expect(callback.origin + callback.pathname).toBe(REDIRECT_URI)
185
+ expect(callback.searchParams.get('code')).not.toBeNull()
186
+ expect(callback.searchParams.get('error')).toBeNull()
187
+ })
188
+ })
189
+
190
+ describe('the account_refused check', () => {
191
+ const check = makeInteractionPolicy().get('login')?.checks.get(ACCOUNT_REFUSED_REASON)
192
+ const run = (oidc: Record<string, unknown>) => check!.check({ oidc } as never)
193
+
194
+ it('is registered on the login prompt', () => {
195
+ expect(check).toBeDefined()
196
+ expect(check?.error).toBe('login_required')
197
+ })
198
+
199
+ it('leaves a loaded account alone', async () => {
200
+ expect(await run({ session: { accountId: 'a' }, account: { accountId: 'a' } }))
201
+ .toBe(interactionPolicy.Check.NO_NEED_TO_PROMPT)
202
+ })
203
+
204
+ it('leaves a request with no session account to the provider own check', async () => {
205
+ expect(await run({ session: {} })).toBe(interactionPolicy.Check.NO_NEED_TO_PROMPT)
206
+ })
207
+
208
+ it('asks a refused session to log in', async () => {
209
+ expect(await run({ session: { accountId: 'a' } })).toBe(interactionPolicy.Check.REQUEST_PROMPT)
210
+ })
211
+
212
+ it('denies, not loops, when the login that just completed is refused too', () => {
213
+ expect(() => run({ session: { accountId: 'a' }, result: { login: { accountId: 'a' } } }))
214
+ .toThrow(errors.AccessDenied)
215
+ })
216
+ })