@owlmeans/server-oidc-provider 0.1.18-rc.45 → 0.1.18-rc.46

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.45
17
+ bun add @owlmeans/server-oidc-provider@^0.1.18-rc.46
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.44
75
+ npx @owlmeans/agent-skills@^0.1.18-rc.45
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.45",
5
- "generatedAt": "2026-09-30T09:41:02.835Z",
4
+ "version": "0.1.18-rc.46",
5
+ "generatedAt": "2026-10-01T23:27:48.745Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: server-oidc-provider
3
- description: How to use @owlmeans/server-oidc-provider — the embedded OIDC identity provider on top of the oidc-provider library — service wiring, the account and adapter seams, the interaction URL, how scopes are derived from claims, and the response headers an authorization endpoint has to correct. Auto-invoked when serving OIDC endpoints from your own service.
3
+ description: How to use @owlmeans/server-oidc-provider — the embedded OIDC identity provider on top of the oidc-provider library — service wiring, the account and adapter seams, the interaction URL, how scopes are derived from claims, the organizations claim, extra discovery fields, pairwise subjects, and the response headers an authorization endpoint has to correct. Auto-invoked when serving OIDC endpoints from your own service.
4
4
  user-invocable: false
5
5
  ---
6
6
  <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
@@ -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.45"` in `dependencies`
11
+ **Install:** `"@owlmeans/server-oidc-provider": "^0.1.18-rc.46"` 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,
@@ -26,6 +26,7 @@ use `@owlmeans/server-oidc-rp`.
26
26
  | `OidcAdapterService` | The storage seam: `instance(name)` returns an `oidc-provider` `Adapter` |
27
27
  | `OIDC_ACCOUNT_SERVICE`, `DEFAULT_ALIAS` | The default account-service alias and the default provider alias |
28
28
  | `OidcConfig`, `OidcConfigAppend` | The `cfg.oidc` shape this package reads |
29
+ | `OidcCustomConfiguration` | `oidc-provider`'s `Configuration` plus `sectorIdentifierUriValidate`, which the pinned typings omit — the type of `cfg.oidc.customConfiguration` |
29
30
  | `OidcAccountParams` | `{ clientId? }` — lets the account service scope its claims per client |
30
31
  | `toClientMetadata(client)` | Maps a stored `OidcRegisteredClient` to `oidc-provider` `ClientMetadata` |
31
32
  | `OidcRegisteredClient`, `OidcClientMetadata` | The stored-client shape `toClientMetadata` takes, and the `oidc-provider` metadata shape it returns, carrying `owlEntityId` |
@@ -42,12 +43,16 @@ context.registerMiddleware(createOidcProviderMiddleware())
42
43
 
43
44
  `cfg.oidc` supplies `clients` (`ClientMetadata[]`), `defaultKeys.RS256.pk` (a PKCS#8 PEM), and
44
45
  optionally `basePath` (default `oidc`), `authService` (whose registered route the issuer is built
45
- from — defaults to this service), `behindProxy`, `accountService`, `adapterService` and
46
- `customConfiguration` (merged over the defaults below). The shape also declares `frontBase`, which
46
+ from — defaults to this service), `behindProxy`, `accountService`, `adapterService`,
47
+ `discoveryUris` (below) and `customConfiguration` (merged over the defaults below). The shape also declares `frontBase`, which
47
48
  this package reads nowhere — setting it configures nothing.
48
49
 
49
50
  A client's `redirect_uris` and `post_logout_redirect_uris` may be written as `{{service-alias}}/path`
50
- and are expanded against that registered service's host. A client with no `client_secret` is refused
51
+ and are expanded against that registered service's host. `discoveryUris` (field → URL) extends the
52
+ discovery document through the same expansion, merged over `customConfiguration.discovery` — that
53
+ is how a deployment advertises an endpoint another service serves, such as `IAM_API_METADATA`
54
+ (`owlmeans_iam_api`, the runtime IAM API). A field the provider derives itself cannot be overridden
55
+ this way. A client with no `client_secret` is refused
51
56
  unless a debug flag is on, in which case one is generated and printed.
52
57
 
53
58
  ## Building the interaction URL
@@ -70,9 +75,36 @@ client's own `scope` allowlist: a client that omits one fails the whole authoriz
70
75
  no claim behind it is silently empty.
71
76
 
72
77
  The defaults this package sets: `claims` covers `email` (`email`, `email_verified`), `profile` (the
73
- standard set) and `PERMISSIONS_SCOPE` → `PERMISSIONS_CLAIM`, which stays inert unless the account
74
- service actually emits it; `scopes` is `openid profile offline_access permissions`;
75
- `features.devInteractions` is off. Anything under `cfg.oidc.customConfiguration` merges over these.
78
+ standard set), `PERMISSIONS_SCOPE` → `PERMISSIONS_CLAIM` and `ORGANIZATIONS_SCOPE` →
79
+ `ORGANIZATIONS_CLAIM`, both inert unless the account service actually emits them; `scopes` is
80
+ `openid profile offline_access permissions organizations`; `features.devInteractions` is off.
81
+ Anything under `cfg.oidc.customConfiguration` merges over these.
82
+
83
+ `organizations` is a static scope like any other: only a client whose allowlist names it may ask,
84
+ and a client that does not ask gets no `organizations` claim at all — which is what keeps a
85
+ pre-tenancy client's tokens exactly as they were.
86
+
87
+ ## Pairwise subjects
88
+
89
+ The integrated IAM gives every client its own subject for one account. `subjectTypes`,
90
+ `pairwiseIdentifier` and `sectorIdentifierUriValidate` pass through `customConfiguration`
91
+ unchanged, and `tests/pairwise.spec.ts` drives the pinned provider through the whole design:
92
+
93
+ - `subjectTypes: ['pairwise']`, `pairwiseIdentifier: (ctx, accountId, client) => <subject>`, and
94
+ `sectorIdentifierUriValidate: () => false`; each client carries `subject_type: 'pairwise'` and
95
+ one constant `sector_identifier_uri` (https, never fetched, so its host may serve nothing — it
96
+ only lets a client register redirect URIs on several hosts).
97
+ - The session and the grant name the ACCOUNT; `findAccount` is only ever asked for the account
98
+ id. The id_token, userinfo and introspection all answer the pairwise subject, unchanged across a
99
+ refresh; another client of the same session gets another one.
100
+ - **The account's `claims()` must return `sub` = the account id.** The provider applies
101
+ `pairwiseIdentifier` to whatever `sub` it is given (`helpers/claims.js`), and introspection to
102
+ `token.accountId` — an account service that returned the pairwise value itself would have it
103
+ transformed a second time, and the id_token would no longer match introspection.
104
+ - With only `pairwise` supported it becomes every client's default `subject_type`; a client
105
+ declaring `public` is refused.
106
+ - `sectorIdentifierUriValidate` is missing from `@types/oidc-provider` 9.5.0, which is why
107
+ `OidcCustomConfiguration` declares it; drop that field when the typings carry it.
76
108
 
77
109
  ## oidc-provider v9 configuration
78
110
 
package/build/types.d.ts CHANGED
@@ -17,7 +17,14 @@ export interface OidcConfig extends OidcSharedConfig {
17
17
  basePath?: string;
18
18
  frontBase?: string;
19
19
  clients: ClientMetadata[];
20
- customConfiguration?: Configuration;
20
+ /**
21
+ * Extra fields of the discovery document, field name → URL. A value may be written as
22
+ * `{{service-alias}}/path`, expanded against that registered service's host exactly as a
23
+ * client's redirect URIs are — which is how a deployment advertises an endpoint it serves on
24
+ * another service (`IAM_API_METADATA`).
25
+ */
26
+ discoveryUris?: Record<string, string>;
27
+ customConfiguration?: OidcCustomConfiguration;
21
28
  behindProxy?: boolean;
22
29
  defaultKeys: {
23
30
  RS256: {
@@ -28,6 +35,16 @@ export interface OidcConfig extends OidcSharedConfig {
28
35
  accountService?: string;
29
36
  adapterService?: string;
30
37
  }
38
+ /**
39
+ * The provider configuration a deployment passes through, merged over this package's defaults.
40
+ *
41
+ * Adds what the pinned `@types/oidc-provider` omits but `oidc-provider` reads:
42
+ * `sectorIdentifierUriValidate` answering `false` keeps the provider from fetching a pairwise
43
+ * client's `sector_identifier_uri` — a constant sector whose host serves nothing.
44
+ */
45
+ export interface OidcCustomConfiguration extends Configuration {
46
+ sectorIdentifierUriValidate?: (client: InstanceType<Provider['Client']>) => boolean;
47
+ }
31
48
  export interface OidcAccountParams {
32
49
  /** The OIDC client requesting the account — lets the account service scope claims (e.g. permissions) per client. */
33
50
  clientId?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAA;AAC3D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AACtD,OAAO,KAAK,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtE,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAC3E,OAAO,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAE3G,MAAM,WAAW,mBAAoB,SAAQ,kBAAkB;IAC7D,IAAI,EAAE,QAAQ,CAAA;IAEd,MAAM,EAAE,CAAC,GAAG,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAEzC,QAAQ,EAAE,MAAM,QAAQ,CAAA;IAExB,cAAc,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAA;CAC5D;AAED,MAAM,WAAW,gBAAgB,CAAC,KAAK,SAAS,gBAAgB,GAAG,gBAAgB;IACjF,IAAI,EAAE,UAAU,GAAG,KAAK,CAAA;CACzB;AAED,MAAM,WAAW,UAAW,SAAQ,gBAAgB;IAClD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,OAAO,EAAE,cAAc,EAAE,CAAA;IACzB,mBAAmB,CAAC,EAAE,aAAa,CAAA;IACnC,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,WAAW,EAAE;QACX,KAAK,EAAE;YACL,EAAE,EAAE,MAAM,CAAA;YACV,GAAG,CAAC,EAAE,MAAM,CAAA;SACb,CAAA;KACF,CAAA;IACD,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,WAAW,iBAAiB;IAChC,oHAAoH;IACpH,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,kBAAmB,SAAQ,kBAAkB;IAC5D,QAAQ,EAAE,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,OAAO,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,iBAAiB,KAAK,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC,CAAA;CACnI;AAED,MAAM,WAAW,kBAAmB,SAAQ,kBAAkB;IAC5D,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAA;CACpC;AAED,MAAM,WAAW,MAAO,SAAQ,YAAY,EAAE,gBAAgB;IAC5D,KAAK,EAAE,YAAY,CAAC,OAAO,CAAC,GAAG;QAC7B,IAAI,CAAC,EAAE,OAAO,CAAA;QACd,UAAU,CAAC,EAAE,OAAO,CAAA;QACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;KACnB,CAAA;CACF;AAED,MAAM,WAAW,OAAO,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,CAAE,SAAQ,aAAa,CAAC,CAAC,CAAC,EACxE,eAAe;CAAI"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAA;AAC3D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AACtD,OAAO,KAAK,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtE,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAC3E,OAAO,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAE3G,MAAM,WAAW,mBAAoB,SAAQ,kBAAkB;IAC7D,IAAI,EAAE,QAAQ,CAAA;IAEd,MAAM,EAAE,CAAC,GAAG,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;IAEzC,QAAQ,EAAE,MAAM,QAAQ,CAAA;IAExB,cAAc,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAA;CAC5D;AAED,MAAM,WAAW,gBAAgB,CAAC,KAAK,SAAS,gBAAgB,GAAG,gBAAgB;IACjF,IAAI,EAAE,UAAU,GAAG,KAAK,CAAA;CACzB;AAED,MAAM,WAAW,UAAW,SAAQ,gBAAgB;IAClD,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,OAAO,EAAE,cAAc,EAAE,CAAA;IACzB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACtC,mBAAmB,CAAC,EAAE,uBAAuB,CAAA;IAC7C,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,WAAW,EAAE;QACX,KAAK,EAAE;YACL,EAAE,EAAE,MAAM,CAAA;YACV,GAAG,CAAC,EAAE,MAAM,CAAA;SACb,CAAA;KACF,CAAA;IACD,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,uBAAwB,SAAQ,aAAa;IAC5D,2BAA2B,CAAC,EAAE,CAAC,MAAM,EAAE,YAAY,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,KAAK,OAAO,CAAA;CACpF;AAED,MAAM,WAAW,iBAAiB;IAChC,oHAAoH;IACpH,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,kBAAmB,SAAQ,kBAAkB;IAC5D,QAAQ,EAAE,CAAC,CAAC,SAAS,MAAM,EAAE,CAAC,SAAS,OAAO,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,iBAAiB,KAAK,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC,CAAA;CACnI;AAED,MAAM,WAAW,kBAAmB,SAAQ,kBAAkB;IAC5D,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAA;CACpC;AAED,MAAM,WAAW,MAAO,SAAQ,YAAY,EAAE,gBAAgB;IAC5D,KAAK,EAAE,YAAY,CAAC,OAAO,CAAC,GAAG;QAC7B,IAAI,CAAC,EAAE,OAAO,CAAA;QACd,UAAU,CAAC,EAAE,OAAO,CAAA;QACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;KACnB,CAAA;CACF;AAED,MAAM,WAAW,OAAO,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,CAAE,SAAQ,aAAa,CAAC,CAAC,CAAC,EACxE,eAAe;CAAI"}
@@ -1,4 +1,7 @@
1
1
  import type { ClientMetadata } from 'oidc-provider';
2
2
  import type { Context } from '../types.js';
3
+ import type { SecurityHelper } from '@owlmeans/config';
3
4
  export declare const updateClient: (context: Context, client: ClientMetadata) => ClientMetadata;
5
+ /** Expands a `{{service-alias}}/path` URI against that registered service; any other URI is kept. */
6
+ export declare const makeUriUpdater: (context: Context, helper: SecurityHelper) => (uri: string) => string;
4
7
  //# sourceMappingURL=client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/utils/client.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AACnD,OAAO,KAAK,EAAU,OAAO,EAAE,MAAM,aAAa,CAAA;AAKlD,eAAO,MAAM,YAAY,YAAa,OAAO,UAAU,cAAc,KAAG,cAoBvE,CAAA"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/utils/client.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAA;AACnD,OAAO,KAAK,EAAU,OAAO,EAAE,MAAM,aAAa,CAAA;AAElD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAGtD,eAAO,MAAM,YAAY,YAAa,OAAO,UAAU,cAAc,KAAG,cAoBvE,CAAA;AAED,qGAAqG;AACrG,eAAO,MAAM,cAAc,YAAa,OAAO,UAAU,cAAc,WAAW,MAAM,KAAG,MAS1F,CAAA"}
@@ -20,7 +20,8 @@ export const updateClient = (context, client) => {
20
20
  client.post_logout_redirect_uris = client.post_logout_redirect_uris?.map(updateUri) ?? [];
21
21
  return client;
22
22
  };
23
- const makeUriUpdater = (context, helper) => (uri) => {
23
+ /** Expands a `{{service-alias}}/path` URI against that registered service; any other URI is kept. */
24
+ export const makeUriUpdater = (context, helper) => (uri) => {
24
25
  if (uri.startsWith('{{')) {
25
26
  const [host, ...parts] = uri.split(SEP);
26
27
  const service = context.cfg.services[host.slice(2, -2)];
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/utils/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACjD,OAAO,EAAE,GAAG,EAAE,MAAM,aAAa,CAAA;AAGjC,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAA;AAErD,OAAO,EAAE,GAAG,EAAE,MAAM,iBAAiB,CAAA;AAErC,MAAM,CAAC,MAAM,YAAY,GAAG,CAAC,OAAgB,EAAE,MAAsB,EAAkB,EAAE;IACvF,IAAI,MAAM,CAAC,aAAa,IAAI,IAAI,EAAE,CAAC;QACjC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;YACtD,MAAM,IAAI,WAAW,CAAC,2BAA2B,CAAC,CAAA;QACpD,CAAC;QACD,MAAM,CAAC,aAAa,GAAG,GAAG,CAAC,MAAM,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC,CAAA;QAElD,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAClB,OAAO,CAAC,IAAI,CAAC,wCAAwC,CAAC,CAAA;QACtD,OAAO,CAAC,IAAI,CAAC,wEAAwE,EAAE,MAAM,CAAC,aAAa,CAAC,CAAA;QAC5G,OAAO,CAAC,IAAI,CAAC,wCAAwC,CAAC,CAAA;QACtD,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACpB,CAAC;IAED,MAAM,MAAM,GAAG,kBAAkB,CAAkB,OAAO,CAAC,CAAA;IAC3D,MAAM,SAAS,GAAG,cAAc,CAAC,OAAO,EAAE,MAAM,CAAC,CAAA;IACjD,MAAM,CAAC,aAAa,GAAG,MAAM,CAAC,aAAa,EAAE,GAAG,CAAC,SAAS,CAAC,IAAI,EAAE,CAAA;IACjE,MAAM,CAAC,yBAAyB,GAAG,MAAM,CAAC,yBAAyB,EAAE,GAAG,CAAC,SAAS,CAAC,IAAI,EAAE,CAAA;IAEzF,OAAO,MAAM,CAAA;AACf,CAAC,CAAA;AAED,MAAM,cAAc,GAAG,CAAC,OAAgB,EAAE,MAAsB,EAAE,EAAE,CAAC,CAAC,GAAW,EAAU,EAAE;IAC3F,IAAI,GAAG,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;QACzB,MAAM,CAAC,IAAI,EAAE,GAAG,KAAK,CAAC,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;QAEvC,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAA;QACvD,OAAO,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAA;IACjD,CAAC;IAED,OAAO,GAAG,CAAA;AACZ,CAAC,CAAA"}
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/utils/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACjD,OAAO,EAAE,GAAG,EAAE,MAAM,aAAa,CAAA;AAGjC,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAA;AAErD,OAAO,EAAE,GAAG,EAAE,MAAM,iBAAiB,CAAA;AAErC,MAAM,CAAC,MAAM,YAAY,GAAG,CAAC,OAAgB,EAAE,MAAsB,EAAkB,EAAE;IACvF,IAAI,MAAM,CAAC,aAAa,IAAI,IAAI,EAAE,CAAC;QACjC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;YACtD,MAAM,IAAI,WAAW,CAAC,2BAA2B,CAAC,CAAA;QACpD,CAAC;QACD,MAAM,CAAC,aAAa,GAAG,GAAG,CAAC,MAAM,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC,CAAA;QAElD,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QAClB,OAAO,CAAC,IAAI,CAAC,wCAAwC,CAAC,CAAA;QACtD,OAAO,CAAC,IAAI,CAAC,wEAAwE,EAAE,MAAM,CAAC,aAAa,CAAC,CAAA;QAC5G,OAAO,CAAC,IAAI,CAAC,wCAAwC,CAAC,CAAA;QACtD,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACpB,CAAC;IAED,MAAM,MAAM,GAAG,kBAAkB,CAAkB,OAAO,CAAC,CAAA;IAC3D,MAAM,SAAS,GAAG,cAAc,CAAC,OAAO,EAAE,MAAM,CAAC,CAAA;IACjD,MAAM,CAAC,aAAa,GAAG,MAAM,CAAC,aAAa,EAAE,GAAG,CAAC,SAAS,CAAC,IAAI,EAAE,CAAA;IACjE,MAAM,CAAC,yBAAyB,GAAG,MAAM,CAAC,yBAAyB,EAAE,GAAG,CAAC,SAAS,CAAC,IAAI,EAAE,CAAA;IAEzF,OAAO,MAAM,CAAA;AACf,CAAC,CAAA;AAED,qGAAqG;AACrG,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,OAAgB,EAAE,MAAsB,EAAE,EAAE,CAAC,CAAC,GAAW,EAAU,EAAE;IAClG,IAAI,GAAG,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;QACzB,MAAM,CAAC,IAAI,EAAE,GAAG,KAAK,CAAC,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;QAEvC,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAA;QACvD,OAAO,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAA;IACjD,CAAC;IAED,OAAO,GAAG,CAAA;AACZ,CAAC,CAAA"}
@@ -1,4 +1,3 @@
1
- import type { Context } from '../types.js';
2
- import type { Configuration } from 'oidc-provider';
3
- export declare const combineConfig: (context: Context, _unsecure: boolean) => Promise<Configuration>;
1
+ import type { Context, OidcCustomConfiguration } from '../types.js';
2
+ export declare const combineConfig: (context: Context, _unsecure: boolean) => Promise<OidcCustomConfiguration>;
4
3
  //# sourceMappingURL=config.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/utils/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAA;AAC1C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAA;AAKlD,eAAO,MAAM,aAAa,YAAmB,OAAO,aAAa,OAAO,KAAG,OAAO,CAAC,aAAa,CA0C/F,CAAA"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/utils/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAA;AAMnE,eAAO,MAAM,aAAa,YAAmB,OAAO,aAAa,OAAO,KAAG,OAAO,CAAC,uBAAuB,CAiDzG,CAAA"}
@@ -1,8 +1,10 @@
1
- import { updateClient } from './client.js';
2
- import { PERMISSIONS_CLAIM, PERMISSIONS_SCOPE } from '@owlmeans/oidc';
1
+ import { makeUriUpdater, updateClient } from './client.js';
2
+ import { ORGANIZATIONS_CLAIM, ORGANIZATIONS_SCOPE, PERMISSIONS_CLAIM, PERMISSIONS_SCOPE } from '@owlmeans/oidc';
3
+ import { makeSecurityHelper } from '@owlmeans/config';
3
4
  import * as jose from 'jose';
4
5
  export const combineConfig = async (context, _unsecure) => {
5
6
  const cfg = context.cfg.oidc;
7
+ const updateUri = makeUriUpdater(context, makeSecurityHelper(context));
6
8
  const configuration = {
7
9
  ...cfg.customConfiguration,
8
10
  clients: [
@@ -17,12 +19,18 @@ export const combineConfig = async (context, _unsecure) => {
17
19
  ],
18
20
  // Inert unless the account service actually emits the claim (integrated IAM mode)
19
21
  [PERMISSIONS_SCOPE]: [PERMISSIONS_CLAIM],
22
+ // Inert likewise — and a static scope, so only a client whose allowlist names it may ask
23
+ [ORGANIZATIONS_SCOPE]: [ORGANIZATIONS_CLAIM],
20
24
  ...cfg.customConfiguration?.claims,
21
25
  },
22
26
  scopes: [
23
- 'openid', 'profile', 'offline_access', PERMISSIONS_SCOPE,
27
+ 'openid', 'profile', 'offline_access', PERMISSIONS_SCOPE, ORGANIZATIONS_SCOPE,
24
28
  ...cfg.customConfiguration?.scopes ?? []
25
29
  ],
30
+ discovery: {
31
+ ...cfg.customConfiguration?.discovery,
32
+ ...Object.fromEntries(Object.entries(cfg.discoveryUris ?? {}).map(([field, uri]) => [field, updateUri(uri)])),
33
+ },
26
34
  features: {
27
35
  ...cfg.customConfiguration?.features,
28
36
  devInteractions: { enabled: false }
@@ -1 +1 @@
1
- {"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/utils/config.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC1C,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAA;AACrE,OAAO,KAAK,IAAI,MAAM,MAAM,CAAA;AAE5B,MAAM,CAAC,MAAM,aAAa,GAAG,KAAK,EAAE,OAAgB,EAAE,SAAkB,EAA0B,EAAE;IAClG,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAA;IAE5B,MAAM,aAAa,GAAkB;QACnC,GAAG,GAAG,CAAC,mBAAmB;QAC1B,OAAO,EAAE;YACP,GAAG,GAAG,CAAC,OAAO;YACd,GAAG,CAAC,GAAG,CAAC,mBAAmB,EAAE,OAAO,IAAI,EAAE,CAAC;SAC5C,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QAC9C,MAAM,EAAE;YACN,KAAK,EAAE,CAAC,OAAO,EAAE,gBAAgB,EAAE,GAAG,GAAG,CAAC,mBAAmB,EAAE,MAAM,EAAE,KAAK,IAAI,EAAE,CAAC;YACnF,OAAO,EAAE;gBACP,UAAU,EAAE,aAAa,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,oBAAoB;gBAC3F,GAAG,GAAG,CAAC,mBAAmB,EAAE,MAAM,EAAE,OAAO,IAAI,EAAE;aAClD;YACD,kFAAkF;YAClF,CAAC,iBAAiB,CAAC,EAAE,CAAC,iBAAiB,CAAC;YACxC,GAAG,GAAG,CAAC,mBAAmB,EAAE,MAAM;SACnC;QACD,MAAM,EAAE;YACN,QAAQ,EAAE,SAAS,EAAE,gBAAgB,EAAE,iBAAiB;YACxD,GAAG,GAAG,CAAC,mBAAmB,EAAE,MAAM,IAAI,EAAE;SACzC;QACD,QAAQ,EAAE;YACR,GAAG,GAAG,CAAC,mBAAmB,EAAE,QAAQ;YACpC,eAAe,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE;YACnC,qBAAqB;YACrB,eAAe;YACf,kEAAkE;YAClE,gCAAgC;YAChC,mBAAmB;YACnB,2DAA2D;YAC3D,KAAK;SACN;QACD,IAAI,EAAE;YACJ,IAAI,EAAE;gBACJ,MAAM,IAAI,CAAC,SAAS,CAAC,MAAM,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,WAAW,CAAC,KAAK,CAAC,EAAE,EAAE,OAAO,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,CAAC;aACvG;SACF;KACF,CAAA;IAED,OAAO,aAAa,CAAA;AACtB,CAAC,CAAA"}
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/utils/config.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC1D,OAAO,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAA;AAC/G,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAA;AACrD,OAAO,KAAK,IAAI,MAAM,MAAM,CAAA;AAE5B,MAAM,CAAC,MAAM,aAAa,GAAG,KAAK,EAAE,OAAgB,EAAE,SAAkB,EAAoC,EAAE;IAC5G,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAA;IAC5B,MAAM,SAAS,GAAG,cAAc,CAAC,OAAO,EAAE,kBAAkB,CAAC,OAAO,CAAC,CAAC,CAAA;IAEtE,MAAM,aAAa,GAA4B;QAC7C,GAAG,GAAG,CAAC,mBAAmB;QAC1B,OAAO,EAAE;YACP,GAAG,GAAG,CAAC,OAAO;YACd,GAAG,CAAC,GAAG,CAAC,mBAAmB,EAAE,OAAO,IAAI,EAAE,CAAC;SAC5C,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QAC9C,MAAM,EAAE;YACN,KAAK,EAAE,CAAC,OAAO,EAAE,gBAAgB,EAAE,GAAG,GAAG,CAAC,mBAAmB,EAAE,MAAM,EAAE,KAAK,IAAI,EAAE,CAAC;YACnF,OAAO,EAAE;gBACP,UAAU,EAAE,aAAa,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,oBAAoB;gBAC3F,GAAG,GAAG,CAAC,mBAAmB,EAAE,MAAM,EAAE,OAAO,IAAI,EAAE;aAClD;YACD,kFAAkF;YAClF,CAAC,iBAAiB,CAAC,EAAE,CAAC,iBAAiB,CAAC;YACxC,yFAAyF;YACzF,CAAC,mBAAmB,CAAC,EAAE,CAAC,mBAAmB,CAAC;YAC5C,GAAG,GAAG,CAAC,mBAAmB,EAAE,MAAM;SACnC;QACD,MAAM,EAAE;YACN,QAAQ,EAAE,SAAS,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,mBAAmB;YAC7E,GAAG,GAAG,CAAC,mBAAmB,EAAE,MAAM,IAAI,EAAE;SACzC;QACD,SAAS,EAAE;YACT,GAAG,GAAG,CAAC,mBAAmB,EAAE,SAAS;YACrC,GAAG,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,EAAE,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;SAC9G;QACD,QAAQ,EAAE;YACR,GAAG,GAAG,CAAC,mBAAmB,EAAE,QAAQ;YACpC,eAAe,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE;YACnC,qBAAqB;YACrB,eAAe;YACf,kEAAkE;YAClE,gCAAgC;YAChC,mBAAmB;YACnB,2DAA2D;YAC3D,KAAK;SACN;QACD,IAAI,EAAE;YACJ,IAAI,EAAE;gBACJ,MAAM,IAAI,CAAC,SAAS,CAAC,MAAM,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,WAAW,CAAC,KAAK,CAAC,EAAE,EAAE,OAAO,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,CAAC;aACvG;SACF;KACF,CAAA;IAED,OAAO,aAAa,CAAA;AACtB,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.45",
3
+ "version": "0.1.18-rc.46",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -35,13 +35,13 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@noble/hashes": "^1.5.0",
38
- "@owlmeans/config": "^0.1.18-rc.39",
38
+ "@owlmeans/config": "^0.1.18-rc.40",
39
39
  "@owlmeans/context": "^0.1.18-rc.32",
40
- "@owlmeans/entrypoint": "^0.1.18-rc.37",
41
- "@owlmeans/oidc": "^0.1.18-rc.50",
40
+ "@owlmeans/entrypoint": "^0.1.18-rc.38",
41
+ "@owlmeans/oidc": "^0.1.18-rc.51",
42
42
  "@owlmeans/route": "^0.1.18-rc.33",
43
- "@owlmeans/server-api": "^0.1.18-rc.46",
44
- "@owlmeans/server-context": "^0.1.18-rc.38",
43
+ "@owlmeans/server-api": "^0.1.18-rc.47",
44
+ "@owlmeans/server-context": "^0.1.18-rc.39",
45
45
  "@scure/base": "^2.3.0",
46
46
  "jose": "6.2.5",
47
47
  "oidc-provider": "9.11.1"
package/src/types.ts CHANGED
@@ -23,7 +23,14 @@ export interface OidcConfig extends OidcSharedConfig {
23
23
  basePath?: string
24
24
  frontBase?: string
25
25
  clients: ClientMetadata[]
26
- customConfiguration?: Configuration
26
+ /**
27
+ * Extra fields of the discovery document, field name → URL. A value may be written as
28
+ * `{{service-alias}}/path`, expanded against that registered service's host exactly as a
29
+ * client's redirect URIs are — which is how a deployment advertises an endpoint it serves on
30
+ * another service (`IAM_API_METADATA`).
31
+ */
32
+ discoveryUris?: Record<string, string>
33
+ customConfiguration?: OidcCustomConfiguration
27
34
  behindProxy?: boolean
28
35
  defaultKeys: {
29
36
  RS256: {
@@ -35,6 +42,17 @@ export interface OidcConfig extends OidcSharedConfig {
35
42
  adapterService?: string
36
43
  }
37
44
 
45
+ /**
46
+ * The provider configuration a deployment passes through, merged over this package's defaults.
47
+ *
48
+ * Adds what the pinned `@types/oidc-provider` omits but `oidc-provider` reads:
49
+ * `sectorIdentifierUriValidate` answering `false` keeps the provider from fetching a pairwise
50
+ * client's `sector_identifier_uri` — a constant sector whose host serves nothing.
51
+ */
52
+ export interface OidcCustomConfiguration extends Configuration {
53
+ sectorIdentifierUriValidate?: (client: InstanceType<Provider['Client']>) => boolean
54
+ }
55
+
38
56
  export interface OidcAccountParams {
39
57
  /** The OIDC client requesting the account — lets the account service scope claims (e.g. permissions) per client. */
40
58
  clientId?: string
@@ -28,7 +28,8 @@ export const updateClient = (context: Context, client: ClientMetadata): ClientMe
28
28
  return client
29
29
  }
30
30
 
31
- const makeUriUpdater = (context: Context, helper: SecurityHelper) => (uri: string): string => {
31
+ /** Expands a `{{service-alias}}/path` URI against that registered service; any other URI is kept. */
32
+ export const makeUriUpdater = (context: Context, helper: SecurityHelper) => (uri: string): string => {
32
33
  if (uri.startsWith('{{')) {
33
34
  const [host, ...parts] = uri.split(SEP)
34
35
 
@@ -1,13 +1,14 @@
1
- import type { Context } from '../types.js'
2
- import type { Configuration } from 'oidc-provider'
3
- import { updateClient } from './client.js'
4
- import { PERMISSIONS_CLAIM, PERMISSIONS_SCOPE } from '@owlmeans/oidc'
1
+ import type { Context, OidcCustomConfiguration } from '../types.js'
2
+ import { makeUriUpdater, updateClient } from './client.js'
3
+ import { ORGANIZATIONS_CLAIM, ORGANIZATIONS_SCOPE, PERMISSIONS_CLAIM, PERMISSIONS_SCOPE } from '@owlmeans/oidc'
4
+ import { makeSecurityHelper } from '@owlmeans/config'
5
5
  import * as jose from 'jose'
6
6
 
7
- export const combineConfig = async (context: Context, _unsecure: boolean): Promise<Configuration> => {
7
+ export const combineConfig = async (context: Context, _unsecure: boolean): Promise<OidcCustomConfiguration> => {
8
8
  const cfg = context.cfg.oidc
9
+ const updateUri = makeUriUpdater(context, makeSecurityHelper(context))
9
10
 
10
- const configuration: Configuration = {
11
+ const configuration: OidcCustomConfiguration = {
11
12
  ...cfg.customConfiguration,
12
13
  clients: [
13
14
  ...cfg.clients,
@@ -21,12 +22,18 @@ export const combineConfig = async (context: Context, _unsecure: boolean): Promi
21
22
  ],
22
23
  // Inert unless the account service actually emits the claim (integrated IAM mode)
23
24
  [PERMISSIONS_SCOPE]: [PERMISSIONS_CLAIM],
25
+ // Inert likewise — and a static scope, so only a client whose allowlist names it may ask
26
+ [ORGANIZATIONS_SCOPE]: [ORGANIZATIONS_CLAIM],
24
27
  ...cfg.customConfiguration?.claims,
25
28
  },
26
29
  scopes: [
27
- 'openid', 'profile', 'offline_access', PERMISSIONS_SCOPE,
30
+ 'openid', 'profile', 'offline_access', PERMISSIONS_SCOPE, ORGANIZATIONS_SCOPE,
28
31
  ...cfg.customConfiguration?.scopes ?? []
29
32
  ],
33
+ discovery: {
34
+ ...cfg.customConfiguration?.discovery,
35
+ ...Object.fromEntries(Object.entries(cfg.discoveryUris ?? {}).map(([field, uri]) => [field, updateUri(uri)])),
36
+ },
30
37
  features: {
31
38
  ...cfg.customConfiguration?.features,
32
39
  devInteractions: { enabled: false }
@@ -2,24 +2,30 @@ import { describe, it, expect } from 'bun:test'
2
2
  import { generateKeyPairSync } from 'node:crypto'
3
3
  import { AppType, makeBasicContext } from '@owlmeans/context'
4
4
  import type { BasicContext } from '@owlmeans/context'
5
+ import {
6
+ IAM_API_METADATA, ORGANIZATIONS_CLAIM, ORGANIZATIONS_SCOPE, PERMISSIONS_CLAIM, PERMISSIONS_SCOPE,
7
+ } from '@owlmeans/oidc'
5
8
  import { combineConfig } from '../src/utils/config.js'
6
9
  import type { Config } from '../src/types.js'
7
10
 
8
11
  const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
9
12
  const TEST_PKCS8_PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
10
13
 
11
- const makeTestContext = () => {
14
+ const makeTestContext = (oidc: Partial<Config['oidc']> = {}) => {
12
15
  const cfg: Config = {
13
16
  ready: false,
14
17
  service: 'server-oidc-provider-tests',
15
18
  type: AppType.Backend,
16
- services: {},
19
+ services: {
20
+ 'iam-api': { alias: 'iam-api', host: 'iam.example.test', type: AppType.Backend, service: 'iam-api' },
21
+ },
17
22
  debug: { all: false },
18
23
  oidc: {
19
24
  clients: [],
20
25
  defaultKeys: {
21
26
  RS256: { pk: TEST_PKCS8_PEM },
22
27
  },
28
+ ...oidc,
23
29
  },
24
30
  } as unknown as Config
25
31
 
@@ -38,3 +44,51 @@ describe('combineConfig (jose v6 extractable key)', () => {
38
44
  expect(keys[0].kty).toBe('RSA')
39
45
  })
40
46
  })
47
+
48
+ describe('combineConfig — scopes and the claims behind them', () => {
49
+ it('maps each integrated-IAM scope to its own claim', async () => {
50
+ const config = await combineConfig(makeTestContext() as any, true)
51
+
52
+ expect(config.claims?.[PERMISSIONS_SCOPE]).toEqual([PERMISSIONS_CLAIM])
53
+ expect(config.claims?.[ORGANIZATIONS_SCOPE]).toEqual([ORGANIZATIONS_CLAIM])
54
+ expect(config.scopes).toContain(ORGANIZATIONS_SCOPE)
55
+ expect(config.scopes).toContain(PERMISSIONS_SCOPE)
56
+ })
57
+ })
58
+
59
+ describe('combineConfig — the discovery document', () => {
60
+ it('expands a service-relative field against the registered service', async () => {
61
+ const config = await combineConfig(makeTestContext({
62
+ discoveryUris: { [IAM_API_METADATA]: '{{iam-api}}/iam/api/runtime' },
63
+ }) as any, true)
64
+
65
+ expect((config.discovery as Record<string, unknown>)[IAM_API_METADATA])
66
+ .toBe('https://iam.example.test/iam/api/runtime')
67
+ })
68
+
69
+ it('keeps an absolute field and the custom discovery fields as given', async () => {
70
+ const config = await combineConfig(makeTestContext({
71
+ discoveryUris: { [IAM_API_METADATA]: 'https://elsewhere.example.test/runtime' },
72
+ customConfiguration: { discovery: { service_documentation: 'https://docs.example.test' } },
73
+ }) as any, true)
74
+
75
+ expect(config.discovery).toEqual({
76
+ service_documentation: 'https://docs.example.test',
77
+ [IAM_API_METADATA]: 'https://elsewhere.example.test/runtime',
78
+ })
79
+ })
80
+ })
81
+
82
+ describe('combineConfig — pairwise subjects pass through', () => {
83
+ it('keeps the subject options of the custom configuration', async () => {
84
+ const pairwiseIdentifier = async (_: unknown, accountId: string) => `pairwise:${accountId}`
85
+ const sectorIdentifierUriValidate = () => false
86
+ const config = await combineConfig(makeTestContext({
87
+ customConfiguration: { subjectTypes: ['pairwise'], pairwiseIdentifier, sectorIdentifierUriValidate },
88
+ }) as any, true)
89
+
90
+ expect(config.subjectTypes).toEqual(['pairwise'])
91
+ expect(config.pairwiseIdentifier).toBe(pairwiseIdentifier)
92
+ expect(config.sectorIdentifierUriValidate).toBe(sectorIdentifierUriValidate)
93
+ })
94
+ })
@@ -0,0 +1,259 @@
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 { generateKeyPairSync } from 'node:crypto'
6
+ import Provider from 'oidc-provider'
7
+ import type { ClientMetadata, KoaContextWithOIDC } from 'oidc-provider'
8
+ import { sha256 } from '@noble/hashes/sha2'
9
+ import { base58 } from '@scure/base'
10
+ import { AppType, makeBasicContext } from '@owlmeans/context'
11
+ import { ORGANIZATIONS_CLAIM, ORGANIZATIONS_SCOPE, PERMISSIONS_CLAIM, PERMISSIONS_SCOPE } from '@owlmeans/oidc'
12
+ import { combineConfig } from '../src/utils/config.js'
13
+ import { makeInteractionPolicy } from '../src/utils/policy.js'
14
+ import type { Config } from '../src/types.js'
15
+
16
+ /**
17
+ * The subject design of the integrated IAM, driven through the pinned `oidc-provider`: the session
18
+ * and the grant name the ACCOUNT (its record id, which never leaves the provider), and every
19
+ * client sees its own pairwise subject for it — in the id_token, in userinfo and in introspection.
20
+ */
21
+
22
+ const ACCOUNT = '0123456789abcdef01234567'
23
+ const SECRET = 'pairwise-spec-secret'
24
+ const PREVIEW = 'acme-preview'
25
+ const PRODUCTION = 'acme-production'
26
+ // A constant sector for every client: its host is never fetched, it only lets one client register
27
+ // redirect URIs on several hosts. An unresolvable name proves the provider never fetches it.
28
+ const SECTOR = 'https://sector.invalid/iam'
29
+
30
+ /** The design's subject: the same on every row of one (account, app). */
31
+ const profileIdOf = (service: string, accountId: string): string =>
32
+ `${service}:${base58.encode(sha256(new TextEncoder().encode(`${accountId}:${service}`))).slice(0, 22)}`
33
+
34
+ const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
35
+ const PK = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
36
+
37
+ const ORGANIZATIONS = [{ entitySlug: 'acme', entityKey: 'acme-key', owner: true, home: true }]
38
+ const SETS = [{ scope: PREVIEW, permissions: { 'order--view': true }, entitySlug: 'acme' }]
39
+
40
+ const running: Server[] = []
41
+ afterEach(() => {
42
+ while (running.length > 0) running.pop()?.close()
43
+ })
44
+
45
+ const client = (clientId: string): ClientMetadata => ({
46
+ client_id: clientId,
47
+ client_secret: SECRET,
48
+ redirect_uris: [`https://${clientId}.example.test/callback`, `https://${clientId}.other.test/callback`],
49
+ response_types: ['code'],
50
+ grant_types: ['authorization_code', 'refresh_token'],
51
+ token_endpoint_auth_method: 'client_secret_basic',
52
+ scope: `openid email profile offline_access ${PERMISSIONS_SCOPE} ${ORGANIZATIONS_SCOPE}`,
53
+ subject_type: 'pairwise',
54
+ sector_identifier_uri: SECTOR,
55
+ })
56
+
57
+ /** A real provider configured the way `combineConfig` builds it, with the pairwise options passed through. */
58
+ const start = async () => {
59
+ const server = createServer()
60
+ await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
61
+ running.push(server)
62
+ const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
63
+
64
+ const context = makeBasicContext({
65
+ ready: false, service: 'pairwise-spec', type: AppType.Backend, services: {}, debug: { all: false },
66
+ oidc: {
67
+ clients: [client(PREVIEW), client(PRODUCTION)],
68
+ defaultKeys: { RS256: { pk: PK } },
69
+ customConfiguration: {
70
+ subjectTypes: ['pairwise'],
71
+ pairwiseIdentifier: async (_ctx: KoaContextWithOIDC, accountId: string, owner: { clientId: string }) =>
72
+ profileIdOf(owner.clientId, accountId),
73
+ sectorIdentifierUriValidate: () => false,
74
+ conformIdTokenClaims: false,
75
+ features: { introspection: { enabled: true } },
76
+ cookies: { keys: ['pairwise-spec-cookie-key'] },
77
+ },
78
+ },
79
+ } as unknown as Config)
80
+
81
+ const loaded: string[] = []
82
+ const provider = new Provider(base, {
83
+ ...await combineConfig(context as any, true),
84
+ findAccount: async (_, id) => {
85
+ loaded.push(id)
86
+ return {
87
+ accountId: id,
88
+ claims: async () => ({
89
+ sub: id, email: 'person@example.test', [ORGANIZATIONS_CLAIM]: ORGANIZATIONS, [PERMISSIONS_CLAIM]: SETS,
90
+ }),
91
+ }
92
+ },
93
+ interactions: { policy: makeInteractionPolicy(), url: async (_, interaction) => `${base}/login/${interaction.uid}` },
94
+ })
95
+ server.on('request', provider.callback())
96
+
97
+ return { provider, base, loaded }
98
+ }
99
+
100
+ type Running = Awaited<ReturnType<typeof start>>
101
+
102
+ const makeJar = () => {
103
+ const jar = new Map<string, string>()
104
+
105
+ return {
106
+ get: (name: string) => jar.get(name),
107
+ header: () => [...jar].map(([name, value]) => `${name}=${value}`).join('; '),
108
+ remember: (response: Response) => {
109
+ for (const line of response.headers.getSetCookie()) {
110
+ const [pair] = line.split(';')
111
+ const name = pair.slice(0, pair.indexOf('='))
112
+ const value = pair.slice(pair.indexOf('=') + 1)
113
+ if (value === '') jar.delete(name)
114
+ else jar.set(name, value)
115
+ }
116
+ },
117
+ }
118
+ }
119
+
120
+ const basic = (clientId: string) => `Basic ${Buffer.from(`${clientId}:${SECRET}`).toString('base64')}`
121
+
122
+ const payloadOf = (jwt: string): Record<string, unknown> =>
123
+ JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString())
124
+
125
+ /** Sign the account in to `clientId` the way the application's interaction handler does. */
126
+ const signIn = async (env: Running, clientId: string, scope: string, jar = makeJar()) => {
127
+ const step = async (url: string, init: RequestInit = {}): Promise<Response> => {
128
+ const response = await fetch(url, { ...init, redirect: 'manual', headers: { ...init.headers, cookie: jar.header() } })
129
+ jar.remember(response)
130
+ return response
131
+ }
132
+ const redirectUri = `https://${clientId}.example.test/callback`
133
+
134
+ const asked = await step(`${env.base}/auth?${new URLSearchParams({
135
+ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope, prompt: 'consent',
136
+ })}`)
137
+ const location = new URL(asked.headers.get('location') ?? 'about:blank')
138
+ if (location.pathname.startsWith('/login/')) {
139
+ const interaction = await env.provider.Interaction.find(location.pathname.split('/').pop()!)
140
+ const grant = new env.provider.Grant({ accountId: ACCOUNT, clientId })
141
+ grant.addOIDCScope(scope)
142
+ interaction!.result = { login: { accountId: ACCOUNT }, consent: { grantId: await grant.save() } }
143
+ await interaction!.save(600)
144
+ const done = await step(interaction!.returnTo)
145
+ location.href = done.headers.get('location') ?? 'about:blank'
146
+ }
147
+ const code = location.searchParams.get('code')
148
+ expect(code).not.toBeNull()
149
+
150
+ const tokens = await fetch(`${env.base}/token`, {
151
+ method: 'POST',
152
+ headers: { authorization: basic(clientId), 'content-type': 'application/x-www-form-urlencoded' },
153
+ body: new URLSearchParams({ grant_type: 'authorization_code', code: code!, redirect_uri: redirectUri }),
154
+ }).then(r => r.json()) as Record<string, string>
155
+
156
+ return { tokens, jar }
157
+ }
158
+
159
+ const userinfo = async (env: Running, accessToken: string) =>
160
+ await fetch(`${env.base}/me`, { headers: { authorization: `Bearer ${accessToken}` } })
161
+ .then(r => r.json()) as Record<string, unknown>
162
+
163
+ const introspect = async (env: Running, clientId: string, token: string) =>
164
+ await fetch(`${env.base}/token/introspection`, {
165
+ method: 'POST',
166
+ headers: { authorization: basic(clientId), 'content-type': 'application/x-www-form-urlencoded' },
167
+ body: new URLSearchParams({ token }),
168
+ }).then(r => r.json()) as Record<string, unknown>
169
+
170
+ const SCOPE = `openid email offline_access ${PERMISSIONS_SCOPE} ${ORGANIZATIONS_SCOPE}`
171
+
172
+ describe('pairwise subjects over the pinned oidc-provider', () => {
173
+ it('one subject per client in the id_token, userinfo and introspection', async () => {
174
+ const env = await start()
175
+ const { tokens } = await signIn(env, PREVIEW, SCOPE)
176
+ const subject = profileIdOf(PREVIEW, ACCOUNT)
177
+
178
+ expect(subject).not.toBe(ACCOUNT)
179
+ expect(payloadOf(tokens.id_token).sub).toBe(subject)
180
+ expect((await userinfo(env, tokens.access_token)).sub).toBe(subject)
181
+ const introspected = await introspect(env, PREVIEW, tokens.access_token)
182
+ expect(introspected.active).toBe(true)
183
+ expect(introspected.sub).toBe(subject)
184
+ })
185
+
186
+ it('keeps the subject across a refresh', async () => {
187
+ const env = await start()
188
+ const { tokens } = await signIn(env, PREVIEW, SCOPE)
189
+ expect(tokens.refresh_token).toBeDefined()
190
+
191
+ const refreshed = await fetch(`${env.base}/token`, {
192
+ method: 'POST',
193
+ headers: { authorization: basic(PREVIEW), 'content-type': 'application/x-www-form-urlencoded' },
194
+ body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: tokens.refresh_token }),
195
+ }).then(r => r.json()) as Record<string, string>
196
+
197
+ expect(payloadOf(refreshed.id_token).sub).toBe(profileIdOf(PREVIEW, ACCOUNT))
198
+ })
199
+
200
+ it('the session and the account service see the account id, never the subject', async () => {
201
+ const env = await start()
202
+ const { jar } = await signIn(env, PREVIEW, SCOPE)
203
+
204
+ const session = await env.provider.Session.find(jar.get('_session')!)
205
+ expect(session?.accountId).toBe(ACCOUNT)
206
+ expect(env.loaded.length).toBeGreaterThan(0)
207
+ expect(new Set(env.loaded)).toEqual(new Set([ACCOUNT]))
208
+ })
209
+
210
+ it('another client of the same account gets another subject from the same session', async () => {
211
+ const env = await start()
212
+ const preview = await signIn(env, PREVIEW, SCOPE)
213
+ const production = await signIn(env, PRODUCTION, SCOPE, preview.jar)
214
+
215
+ const subject = profileIdOf(PRODUCTION, ACCOUNT)
216
+ expect(payloadOf(production.tokens.id_token).sub).toBe(subject)
217
+ expect(subject).not.toBe(profileIdOf(PREVIEW, ACCOUNT))
218
+ expect((await introspect(env, PRODUCTION, production.tokens.access_token)).sub).toBe(subject)
219
+ })
220
+ })
221
+
222
+ describe('the organizations scope', () => {
223
+ it('carries the organizations claim and the full permission claim when granted', async () => {
224
+ const env = await start()
225
+ const { tokens } = await signIn(env, PREVIEW, SCOPE)
226
+ const claims = payloadOf(tokens.id_token)
227
+
228
+ expect(claims[ORGANIZATIONS_CLAIM]).toEqual(ORGANIZATIONS)
229
+ expect(claims[PERMISSIONS_CLAIM]).toEqual(SETS)
230
+ expect((await userinfo(env, tokens.access_token))[ORGANIZATIONS_CLAIM]).toEqual(ORGANIZATIONS)
231
+ })
232
+
233
+ it('emits no organizations claim to a client that did not ask for the scope', async () => {
234
+ const env = await start()
235
+ const { tokens } = await signIn(env, PREVIEW, `openid email ${PERMISSIONS_SCOPE}`)
236
+ const claims = payloadOf(tokens.id_token)
237
+
238
+ expect(claims[ORGANIZATIONS_CLAIM]).toBeUndefined()
239
+ expect(claims[PERMISSIONS_CLAIM]).toEqual(SETS)
240
+ expect((await userinfo(env, tokens.access_token))[ORGANIZATIONS_CLAIM]).toBeUndefined()
241
+ })
242
+ })
243
+
244
+ describe('client metadata under pairwise-only subjects', () => {
245
+ const providerWith = (metadata: Partial<ClientMetadata>) => new Provider('http://127.0.0.1:1', {
246
+ subjectTypes: ['pairwise'],
247
+ pairwiseIdentifier: async (_ctx, accountId, owner) => profileIdOf(owner.clientId, accountId),
248
+ clients: [{ client_id: PREVIEW, client_secret: SECRET, redirect_uris: ['https://a.example.test/cb'], ...metadata }],
249
+ })
250
+
251
+ it('makes pairwise the default subject type', async () => {
252
+ expect((await providerWith({}).Client.find(PREVIEW))?.subjectType).toBe('pairwise')
253
+ })
254
+
255
+ it('refuses a client that declares a public subject', async () => {
256
+ const failure = await providerWith({ subject_type: 'public' }).Client.find(PREVIEW).catch((error: unknown) => error)
257
+ expect(String(failure)).toContain('invalid_client_metadata')
258
+ })
259
+ })