@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 +2 -2
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-oidc-provider/SKILL.md +40 -8
- package/build/types.d.ts +18 -1
- package/build/types.d.ts.map +1 -1
- package/build/utils/client.d.ts +3 -0
- package/build/utils/client.d.ts.map +1 -1
- package/build/utils/client.js +2 -1
- package/build/utils/client.js.map +1 -1
- package/build/utils/config.d.ts +2 -3
- package/build/utils/config.d.ts.map +1 -1
- package/build/utils/config.js +11 -3
- package/build/utils/config.js.map +1 -1
- package/package.json +6 -6
- package/src/types.ts +19 -1
- package/src/utils/client.ts +2 -1
- package/src/utils/config.ts +14 -7
- package/tests/config.spec.ts +56 -2
- package/tests/pairwise.spec.ts +259 -0
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.
|
|
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.
|
|
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
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/server-oidc-provider",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-
|
|
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.
|
|
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
|
|
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.
|
|
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)
|
|
74
|
-
service actually emits
|
|
75
|
-
`
|
|
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
|
-
|
|
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;
|
package/build/types.d.ts.map
CHANGED
|
@@ -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,
|
|
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"}
|
package/build/utils/client.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/build/utils/client.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
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"}
|
package/build/utils/config.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { Context } from '../types.js';
|
|
2
|
-
|
|
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,
|
|
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"}
|
package/build/utils/config.js
CHANGED
|
@@ -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":"
|
|
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.
|
|
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.
|
|
38
|
+
"@owlmeans/config": "^0.1.18-rc.40",
|
|
39
39
|
"@owlmeans/context": "^0.1.18-rc.32",
|
|
40
|
-
"@owlmeans/entrypoint": "^0.1.18-rc.
|
|
41
|
-
"@owlmeans/oidc": "^0.1.18-rc.
|
|
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.
|
|
44
|
-
"@owlmeans/server-context": "^0.1.18-rc.
|
|
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
|
-
|
|
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
|
package/src/utils/client.ts
CHANGED
|
@@ -28,7 +28,8 @@ export const updateClient = (context: Context, client: ClientMetadata): ClientMe
|
|
|
28
28
|
return client
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
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
|
|
package/src/utils/config.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import type { Context } from '../types.js'
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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<
|
|
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:
|
|
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 }
|
package/tests/config.spec.ts
CHANGED
|
@@ -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
|
+
})
|