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