@idp.global/sdk 19.2.1 → 19.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.md +18 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts_relyingparty/classes.errors.d.ts +9 -1
- package/dist_ts_relyingparty/classes.errors.js +5 -1
- package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +62 -2
- package/dist_ts_relyingparty/classes.relyingpartyroutes.js +260 -12
- package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +108 -1
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +508 -60
- package/dist_ts_relyingparty/classes.rpmodels.d.ts +65 -4
- package/dist_ts_relyingparty/classes.rpmodels.js +217 -5
- package/dist_ts_relyingparty/helpers.bearer.d.ts +48 -0
- package/dist_ts_relyingparty/helpers.bearer.js +199 -0
- package/dist_ts_relyingparty/index.d.ts +1 -0
- package/dist_ts_relyingparty/index.js +2 -1
- package/dist_ts_relyingparty/interfaces.d.ts +111 -1
- package/dist_ts_relyingparty/testing.d.ts +22 -0
- package/dist_ts_relyingparty/testing.js +42 -1
- package/package.json +8 -11
- package/readme.md +153 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_relyingparty/classes.errors.ts +13 -1
- package/ts_relyingparty/classes.relyingpartyroutes.ts +307 -14
- package/ts_relyingparty/classes.relyingpartysessions.ts +610 -61
- package/ts_relyingparty/classes.rpmodels.ts +124 -5
- package/ts_relyingparty/helpers.bearer.ts +260 -0
- package/ts_relyingparty/index.ts +1 -0
- package/ts_relyingparty/interfaces.ts +120 -1
- package/ts_relyingparty/testing.ts +60 -0
package/readme.md
CHANGED
|
@@ -700,6 +700,138 @@ for (const connection of await binding.connectionsOfSession(sessionId)) {
|
|
|
700
700
|
}
|
|
701
701
|
```
|
|
702
702
|
|
|
703
|
+
### Bearer Hand-off
|
|
704
|
+
|
|
705
|
+
Cookies serve the application's own pages. Clients of its own that hold no
|
|
706
|
+
cookie of this origin — a desktop app, a native app, a page framed on another
|
|
707
|
+
origin — sign in through the same idp.global login and receive the relying
|
|
708
|
+
party's own access and refresh tokens instead. The `bearer` option turns this
|
|
709
|
+
on beside the cookie login; no bearer route reads or sets a cookie.
|
|
710
|
+
|
|
711
|
+
```ts
|
|
712
|
+
const models = createRelyingPartyModels({ db: smartdataDb });
|
|
713
|
+
await models.Session.ensureInitialized();
|
|
714
|
+
await models.LoginAttempt.ensureInitialized();
|
|
715
|
+
await models.BearerHandoff.ensureInitialized();
|
|
716
|
+
await models.BearerToken.ensureInitialized();
|
|
717
|
+
|
|
718
|
+
const sessions = new RelyingPartySessions({
|
|
719
|
+
models,
|
|
720
|
+
provider,
|
|
721
|
+
// … publicOrigin, keys, principals and hooks as above
|
|
722
|
+
bearer: {
|
|
723
|
+
returnTargets: {
|
|
724
|
+
web: ['/signed-in'],
|
|
725
|
+
loopback: ['http://127.0.0.1/callback', 'http://[::1]/callback'],
|
|
726
|
+
custom_scheme: ['com.example.app:/oauth/callback'],
|
|
727
|
+
popup: ['https://workspace.example'],
|
|
728
|
+
},
|
|
729
|
+
accessTokenMaxAgeSeconds: 900, // the default
|
|
730
|
+
},
|
|
731
|
+
});
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
The flow, with the routes under the default `/auth/` prefix:
|
|
735
|
+
|
|
736
|
+
1. The client makes a PKCE verifier and its S256 challenge (RFC 7636) and
|
|
737
|
+
opens `GET /auth/bearer/login?client_kind=<kind>&return_target=<target>&code_challenge=<challenge>&code_challenge_method=S256&state=<state>`.
|
|
738
|
+
`state` is optional: the client's own value of 1 to 256 characters of
|
|
739
|
+
`A-Z a-z 0-9 . _ ~ -`, handed back with the outcome. A malformed request
|
|
740
|
+
answers 400 `invalid_bearer_request`, a target that is not configured for
|
|
741
|
+
the kind 400 `return_target_not_allowed`; neither stores anything.
|
|
742
|
+
2. The relying party runs the idp.global login (authorization code with PKCE,
|
|
743
|
+
as for cookies). Its `GET /auth/callback` opens the session and delivers a
|
|
744
|
+
hand-off code to the return target. The code is no token: it is valid for
|
|
745
|
+
one claim within `BEARER_HANDOFF_MAX_AGE_SECONDS` (60), bound to the
|
|
746
|
+
challenge, and only its hash is stored. `web`, `loopback` and
|
|
747
|
+
`custom_scheme` get a redirect with `code` and `state` in the query.
|
|
748
|
+
`popup` gets a page whose one nonce script posts
|
|
749
|
+
`{ type: RELYING_PARTY_BEARER_MESSAGE_TYPE, code, state }` to
|
|
750
|
+
`window.opener` with the registered origin as the target origin — never
|
|
751
|
+
`'*'` — and closes the window. A login that fails delivers `error` instead
|
|
752
|
+
of `code`: `access_denied` when the provider came back without a code, the
|
|
753
|
+
code of a `RelyingPartyRefusal` (its `location` is not followed), or
|
|
754
|
+
`authentication_failed`.
|
|
755
|
+
3. The client claims with `POST /auth/bearer/claim` `{ code, codeVerifier }`.
|
|
756
|
+
The answer, as for `POST /auth/bearer/refresh` `{ refreshToken }`, is
|
|
757
|
+
`{ tokenType: 'Bearer', accessToken, accessTokenExpiresAt, expiresIn, refreshToken, refreshTokenExpiresAt, session: { sessionId, principalId, expiresAt } }`
|
|
758
|
+
(`expiresIn` in seconds, counted on the relying party's clock).
|
|
759
|
+
4. `Authorization: Bearer <accessToken>` authenticates the client:
|
|
760
|
+
`GET /auth/bearer/session` answers `{ session }` (`null` without a live
|
|
761
|
+
one), and the application's own routes call
|
|
762
|
+
`sessions.resolveBearerSession(authorizationHeader)` for the live session.
|
|
763
|
+
`POST /auth/bearer/logout` ends the session of that access token, or of the
|
|
764
|
+
body's `{ refreshToken }` once the access token has expired, and answers
|
|
765
|
+
`{ endedLiveSession }`.
|
|
766
|
+
|
|
767
|
+
Return targets are an exact allow-list per kind, compared by equality:
|
|
768
|
+
|
|
769
|
+
- `web`: paths on the public origin outside the login routes.
|
|
770
|
+
- `loopback`: `http://127.0.0.1/<path>` or `http://[::1]/<path>`, configured
|
|
771
|
+
without a port. The client names its ephemeral port, and any port matches
|
|
772
|
+
(RFC 8252 §7.3); `localhost` is not accepted.
|
|
773
|
+
- `custom_scheme`: whole URIs of a private-use scheme with a dot in it (RFC
|
|
774
|
+
8252 §7.1).
|
|
775
|
+
- `popup`: bare origins.
|
|
776
|
+
|
|
777
|
+
The constructor refuses an entry that is not in its canonical form, carries a
|
|
778
|
+
query or a fragment, or contains a `*`: nothing is a prefix and nothing is a
|
|
779
|
+
wildcard.
|
|
780
|
+
|
|
781
|
+
Tokens and their ends:
|
|
782
|
+
|
|
783
|
+
- An access token lives `accessTokenMaxAgeSeconds` and never past its
|
|
784
|
+
session; the refresh token lives until the session ends, and no refresh
|
|
785
|
+
extends it.
|
|
786
|
+
- A refresh spends its refresh token and answers a new pair; the old access
|
|
787
|
+
token stops working at once. A spent refresh token that comes back ends the
|
|
788
|
+
whole session (`refresh_token_reused`), so a client serializes its refreshes,
|
|
789
|
+
and a client that lost a refresh answer signs in again. A refused refresh
|
|
790
|
+
answers 400 `invalid_bearer_grant`.
|
|
791
|
+
- The first claim consumes the code, whatever its verifier. A wrong verifier
|
|
792
|
+
ends the session (`handoff_verifier_mismatch`), a claimed code that comes
|
|
793
|
+
back ends it too (`handoff_code_reused`, RFC 6749 §4.1.2), and a code that
|
|
794
|
+
expired unclaimed ends its session when it comes back or when the next
|
|
795
|
+
bearer login sweeps it (`handoff_unclaimed`). Each answers 400
|
|
796
|
+
`invalid_bearer_handoff`; reuse is also reported as a
|
|
797
|
+
`bearer_credential_reused` event.
|
|
798
|
+
- A bearer session is a provider login like a cookie session: `onSessionEnded`,
|
|
799
|
+
`endSessionsOfPrincipal` and `AuthorityObserver` apply as they are. A refresh
|
|
800
|
+
rotates the provider token when it is due and ends the session when
|
|
801
|
+
idp.global has ended the login (`invalid_bearer_grant`); an unreachable
|
|
802
|
+
provider answers 503 `authority_unavailable` and spends nothing. Until then
|
|
803
|
+
an access token works for its remaining lifetime. An application that must
|
|
804
|
+
see an idp.global logout sooner observes authority per request
|
|
805
|
+
(`authority.observe({ loginSessionId: live.session.id, … })`).
|
|
806
|
+
|
|
807
|
+
The four API routes answer CORS, including the preflight, for the popup
|
|
808
|
+
origins exactly and never allow credentials; every other origin gets no CORS
|
|
809
|
+
header.
|
|
810
|
+
|
|
811
|
+
The client side:
|
|
812
|
+
|
|
813
|
+
- A web client reads `code` and `state` from its return path, removes them with
|
|
814
|
+
`history.replaceState` and claims at once. Every delivery is sent with
|
|
815
|
+
`Referrer-Policy: no-referrer`.
|
|
816
|
+
- A popup client opens the login with `window.open` (without `noopener`) and
|
|
817
|
+
accepts only the message of the popup it opened, from the relying party's
|
|
818
|
+
public origin, with its own `state`:
|
|
819
|
+
|
|
820
|
+
```ts
|
|
821
|
+
const popup = window.open(loginUrl, 'idp-login', 'popup,width=480,height=640');
|
|
822
|
+
window.addEventListener('message', async (event) => {
|
|
823
|
+
if (event.origin !== 'https://app.example' || event.source !== popup) return;
|
|
824
|
+
const message = event.data;
|
|
825
|
+
if (message?.type !== 'idp.global/relyingparty:bearer-handoff' || message.state !== state) return;
|
|
826
|
+
if (message.error) return showSignInError(message.error);
|
|
827
|
+
const tokens = await fetch('https://app.example/auth/bearer/claim', {
|
|
828
|
+
method: 'POST',
|
|
829
|
+
headers: { 'content-type': 'application/json' },
|
|
830
|
+
body: JSON.stringify({ code: message.code, codeVerifier: verifier }),
|
|
831
|
+
}).then((response) => response.json());
|
|
832
|
+
});
|
|
833
|
+
```
|
|
834
|
+
|
|
703
835
|
### Testing an Application
|
|
704
836
|
|
|
705
837
|
`@idp.global/sdk/relyingparty/testing` exports `RelyingPartyProviderDouble`, an
|
|
@@ -739,20 +871,37 @@ discovery. Once one has succeeded, discovery stays done, as with
|
|
|
739
871
|
`IdpOidcProvider`; `discoveries` counts the calls. The claims of a login are
|
|
740
872
|
those of its sign-in; introspection reads the persona as it is now.
|
|
741
873
|
|
|
874
|
+
For bearer clients, `createBearerPkcePair()` makes a verifier and its S256
|
|
875
|
+
challenge, and `readBearerDelivery(response)` reads a callback answer the way
|
|
876
|
+
its client would: the `code`, `error` and `state` of a redirect's location
|
|
877
|
+
(`target` is the location without its query), or a popup page's posted
|
|
878
|
+
`message` and its target origin.
|
|
879
|
+
|
|
880
|
+
```ts
|
|
881
|
+
import { createBearerPkcePair, readBearerDelivery } from '@idp.global/sdk/relyingparty/testing';
|
|
882
|
+
|
|
883
|
+
const pkce = createBearerPkcePair();
|
|
884
|
+
const start = await fetch(`${appOrigin}/auth/bearer/login?client_kind=web&return_target=/signed-in&code_challenge=${pkce.challenge}&code_challenge_method=S256`, { redirect: 'manual' });
|
|
885
|
+
const callback = idp.callbackUrlFor(new URL(start.headers.get('location')!), new URL('/auth/callback', appOrigin));
|
|
886
|
+
const delivery = await readBearerDelivery(await fetch(callback, { redirect: 'manual' }));
|
|
887
|
+
// delivery.code is claimed with pkce.verifier at /auth/bearer/claim
|
|
888
|
+
```
|
|
889
|
+
|
|
742
890
|
### Relying-Party API Surface
|
|
743
891
|
|
|
744
892
|
| API | Description |
|
|
745
893
|
| --- | --- |
|
|
746
894
|
| `IdpOidcProvider` | Discovery, PKCE authorization URL, code exchange with UserInfo, refresh, revocation and organization introspection for idp.global. Implements `IOidcProvider`. `trustedCaPem` or `fetch` for an issuer under a private CA. |
|
|
747
|
-
| `createRelyingPartyModels()` | Binds the session
|
|
748
|
-
| `RelyingPartySessions<TPrincipal>` | Login start (with an application intent and a prompt)/complete/abandon/finalize, `openSession` (with a shorter lifetime), `resolveCookieSession`, `revalidateSession`, `revalidateLiveSession`, `isSessionLive`, `validateCsrf`, `logout`/`endSession`, `endSessionsOfPrincipal`, `liveProviderToken`. Principals give `isActive` or `resolve` (`IRelyingPartyPrincipals`, `IRelyingPartyPrincipalResolver<TPrincipal>`); with `resolve`, live sessions carry `principal`. |
|
|
895
|
+
| `createRelyingPartyModels()` | Binds the session, login-attempt, bearer hand-off and bearer token models to one `SmartdataDb`, or to a function asked for it on every use, optionally under existing collection names. |
|
|
896
|
+
| `RelyingPartySessions<TPrincipal>` | Login start (with an application intent and a prompt)/complete/abandon/finalize, `openSession` (with a shorter lifetime), `resolveCookieSession`, `revalidateSession`, `revalidateLiveSession`, `isSessionLive`, `validateCsrf`, `logout`/`endSession`, `endSessionsOfPrincipal`, `liveProviderToken`. Principals give `isActive` or `resolve` (`IRelyingPartyPrincipals`, `IRelyingPartyPrincipalResolver<TPrincipal>`); with `resolve`, live sessions carry `principal`. With `bearer`: `startBearerLogin`, `bearerDeliveryOf`, `completeBearerLogin`, `abandonBearerLogin`, `claimBearerHandoff`, `refreshBearerTokens`, `resolveBearerAccessToken`, `resolveBearerSession`, `endBearerSession`. |
|
|
749
897
|
| `AuthorityObserver` | Live organization authority with opt-in positive and negative reuse. |
|
|
750
|
-
| `RelyingPartyRoutes` | `handle(ctx)` for `/auth/login`, `/auth/callback`, `/auth/finalize`, `/auth/session` and `/auth/logout`; `null` for anything else. Takes a typedserver route context. `onLogout` is awaited after a CSRF-checked logout ended the session. |
|
|
898
|
+
| `RelyingPartyRoutes` | `handle(ctx)` for `/auth/login`, `/auth/callback`, `/auth/finalize`, `/auth/session` and `/auth/logout`; `null` for anything else. Takes a typedserver route context. `onLogout` is awaited after a CSRF-checked logout ended the session. In bearer mode also `/auth/bearer/login`, `/auth/bearer/claim`, `/auth/bearer/refresh`, `/auth/bearer/session` and `/auth/bearer/logout`, with CORS for the popup origins only. |
|
|
751
899
|
| `createActorScope()` | AsyncLocalStorage actor context with a TypedRouter request scope. |
|
|
752
900
|
| `RelyingPartySocketBinding` | TypedSocket browser registration, per-request re-validation and session tags; takes a connection wrapper or its peer. |
|
|
753
901
|
| `SecretBox`, `deriveCsrfToken()`, `decodeSecretKey()`, `parseCookies()`, `serializeCookie()`, `sanitizeReturnPath()` | The primitives underneath. |
|
|
902
|
+
| `pkceChallengeOf()`, `verifyPkceChallenge()`, `parseBearerAuthorization()`, `isBearerReturnTargetAllowed()`, `RELYING_PARTY_BEARER_MESSAGE_TYPE`, `BEARER_HANDOFF_MAX_AGE_SECONDS` | The bearer primitives: S256, the `Authorization` header, the return-target match and the popup message type. |
|
|
754
903
|
| `RelyingPartyError`, `RelyingPartyRefusal`, `LoginCallbackError`, `AuthorityTokenError`, `AuthorityRefusedError`, `RelyingPartyDatabaseUnavailableError` | Errors with stable codes; none carries token material. A `RelyingPartyRefusal` may name the application's `location` for the browser. |
|
|
755
|
-
| `RelyingPartyProviderDouble` (`/relyingparty/testing`) | An in-process idp.global for an application's tests: personas, live organization roles, single-use codes, rotating and reuse-detecting refresh tokens, recorded calls and injectable failures. |
|
|
904
|
+
| `RelyingPartyProviderDouble` (`/relyingparty/testing`) | An in-process idp.global for an application's tests: personas, live organization roles, single-use codes, rotating and reuse-detecting refresh tokens, recorded calls and injectable failures. `createBearerPkcePair()` and `readBearerDelivery()` for bearer clients. |
|
|
756
905
|
|
|
757
906
|
## Runtime Notes
|
|
758
907
|
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -5,7 +5,15 @@ export type TRelyingPartyErrorCode =
|
|
|
5
5
|
| 'invalid_login_callback'
|
|
6
6
|
| 'missing_login_intent'
|
|
7
7
|
| 'invalid_or_replayed_login'
|
|
8
|
-
| 'invalid_login_finalize'
|
|
8
|
+
| 'invalid_login_finalize'
|
|
9
|
+
/** a bearer login start the relying party cannot accept: a missing or malformed parameter */
|
|
10
|
+
| 'invalid_bearer_request'
|
|
11
|
+
/** a bearer client named a return target that is not configured for its kind, exactly */
|
|
12
|
+
| 'return_target_not_allowed'
|
|
13
|
+
/** a hand-off code that is unknown, expired, already claimed, or claimed with the wrong verifier */
|
|
14
|
+
| 'invalid_bearer_handoff'
|
|
15
|
+
/** a refresh token that is unknown, rotated, expired, or of a session that is over */
|
|
16
|
+
| 'invalid_bearer_grant';
|
|
9
17
|
|
|
10
18
|
const RELYING_PARTY_ERROR_STATUS: Readonly<Record<TRelyingPartyErrorCode, number>> = {
|
|
11
19
|
login_method_unavailable: 404,
|
|
@@ -13,6 +21,10 @@ const RELYING_PARTY_ERROR_STATUS: Readonly<Record<TRelyingPartyErrorCode, number
|
|
|
13
21
|
missing_login_intent: 400,
|
|
14
22
|
invalid_or_replayed_login: 400,
|
|
15
23
|
invalid_login_finalize: 400,
|
|
24
|
+
invalid_bearer_request: 400,
|
|
25
|
+
return_target_not_allowed: 400,
|
|
26
|
+
invalid_bearer_handoff: 400,
|
|
27
|
+
invalid_bearer_grant: 400,
|
|
16
28
|
};
|
|
17
29
|
|
|
18
30
|
/** A request the login flow refuses; the code is safe to answer, the browser learns nothing more. */
|
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
|
-
import { LoginCallbackError, RelyingPartyError, RelyingPartyRefusal } from './classes.errors.js';
|
|
2
|
+
import { AuthorityTokenError, LoginCallbackError, RelyingPartyError, RelyingPartyRefusal } from './classes.errors.js';
|
|
3
3
|
import type { RelyingPartySessions } from './classes.relyingpartysessions.js';
|
|
4
|
+
import {
|
|
5
|
+
bearerDeliveryLocation,
|
|
6
|
+
bearerPopupContentSecurityPolicy,
|
|
7
|
+
bearerPopupPage,
|
|
8
|
+
parseBearerAuthorization,
|
|
9
|
+
type TRelyingPartyBearerOutcome,
|
|
10
|
+
} from './helpers.bearer.js';
|
|
11
|
+
import { randomToken } from './helpers.crypto.js';
|
|
4
12
|
import { sanitizeReturnPath } from './helpers.returnpath.js';
|
|
5
|
-
import type {
|
|
13
|
+
import type {
|
|
14
|
+
IRelyingPartyBearerDelivery,
|
|
15
|
+
IRelyingPartyBearerLoginRequest,
|
|
16
|
+
IRelyingPartyBearerTokens,
|
|
17
|
+
IRelyingPartySession,
|
|
18
|
+
IResolvedRelyingPartySession,
|
|
19
|
+
} from './interfaces.js';
|
|
6
20
|
|
|
7
21
|
/**
|
|
8
22
|
* The part of an HTTP request context the routes read. `IRequestContext` of
|
|
@@ -42,7 +56,7 @@ export interface IRelyingPartyRoutesOptions<TPrincipal extends object | undefine
|
|
|
42
56
|
* never changes the answer — it is reported as a `hook_failed` route event.
|
|
43
57
|
*/
|
|
44
58
|
onLogout?(eventArg: { session: IRelyingPartySession; endedLiveSession: boolean }): Promise<void>;
|
|
45
|
-
/** the largest logout body read, in bytes; a longer one is cancelled as soon as it passes the limit */
|
|
59
|
+
/** the largest logout, claim or refresh body read, in bytes; a longer one is cancelled as soon as it passes the limit */
|
|
46
60
|
maxBodyBytes?: number;
|
|
47
61
|
/**
|
|
48
62
|
* diagnostics of a failed start or callback; the default writes one JSON
|
|
@@ -55,6 +69,10 @@ export interface IRelyingPartyRoutesOptions<TPrincipal extends object | undefine
|
|
|
55
69
|
| 'auth_callback_failed'
|
|
56
70
|
| 'auth_callback_declined'
|
|
57
71
|
| 'auth_callback_refused'
|
|
72
|
+
/** a bearer login start named something the relying party does not accept; `code` says what */
|
|
73
|
+
| 'auth_bearer_start_refused'
|
|
74
|
+
/** a bearer claim, refresh, session or logout failed for a reason other than its credential */
|
|
75
|
+
| 'auth_bearer_failed'
|
|
58
76
|
/** an application hook of the routes threw; the step it belonged to is complete */
|
|
59
77
|
| 'hook_failed';
|
|
60
78
|
hook?: 'onLogout';
|
|
@@ -87,6 +105,19 @@ const jsonResponse = (bodyArg: object, statusArg: number, headersArg?: Record<st
|
|
|
87
105
|
const redirect = (locationArg: string, headersArg?: Record<string, string>): Response =>
|
|
88
106
|
new Response(null, { status: 302, headers: noStoreHeaders({ Location: locationArg, ...headersArg }) });
|
|
89
107
|
|
|
108
|
+
/** A JSON body's field, or `undefined` for anything that is not an object holding it. */
|
|
109
|
+
const bodyField = (bodyArg: unknown, nameArg: string): unknown =>
|
|
110
|
+
bodyArg && typeof bodyArg === 'object' && Object.hasOwn(bodyArg, nameArg) ? Reflect.get(bodyArg, nameArg) : undefined;
|
|
111
|
+
|
|
112
|
+
/** A query parameter given once; `null` when it is repeated, so it never passes as given. */
|
|
113
|
+
const singleParam = (paramsArg: URLSearchParams, nameArg: string): string | null | undefined => {
|
|
114
|
+
const all = paramsArg.getAll(nameArg);
|
|
115
|
+
return all.length === 0 ? undefined : all.length === 1 ? all[0] : null;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** The routes a bearer client calls with `fetch`, possibly from a popup client's origin. */
|
|
119
|
+
type TBearerApiRoute = 'bearerClaim' | 'bearerRefresh' | 'bearerSession' | 'bearerLogout';
|
|
120
|
+
|
|
90
121
|
/**
|
|
91
122
|
* The HTTP steps of a relying-party login, all under the sessions' login path
|
|
92
123
|
* prefix (default `/auth/`):
|
|
@@ -100,6 +131,24 @@ const redirect = (locationArg: string, headersArg?: Record<string, string>): Res
|
|
|
100
131
|
* - `POST logout` ends the session; it needs this origin and the CSRF token,
|
|
101
132
|
* and `onLogout` runs before it answers
|
|
102
133
|
*
|
|
134
|
+
* With bearer mode on (`RelyingPartySessions` `bearer`), the routes of the
|
|
135
|
+
* application's own clients join them; none of them reads or sets a cookie:
|
|
136
|
+
*
|
|
137
|
+
* - `GET bearer/login?client_kind=…&return_target=…&code_challenge=…&code_challenge_method=S256[&state=…]`
|
|
138
|
+
* redirects to the provider
|
|
139
|
+
* - `GET callback` answers a bearer login's callback by delivering the
|
|
140
|
+
* hand-off code — or the `error` — to the return target: a redirect with
|
|
141
|
+
* `code` (or `error`) and `state` in the query, or for a popup a page that
|
|
142
|
+
* posts them to its opener at the registered origin
|
|
143
|
+
* - `POST bearer/claim` `{ code, codeVerifier }` and `POST bearer/refresh`
|
|
144
|
+
* `{ refreshToken }` answer the tokens
|
|
145
|
+
* - `GET bearer/session` answers the session of `Authorization: Bearer <access token>`
|
|
146
|
+
* - `POST bearer/logout` ends the session of the `Authorization` access token
|
|
147
|
+
* or of the body's `{ refreshToken }`
|
|
148
|
+
*
|
|
149
|
+
* The four API routes answer CORS requests from the popup return targets'
|
|
150
|
+
* origins, exactly, and no others; they never allow credentials.
|
|
151
|
+
*
|
|
103
152
|
* `handle()` answers `null` for every other request, so the application
|
|
104
153
|
* chains its own routes (a local password login) around it. Cookies are only
|
|
105
154
|
* ever set here: a `/typedrequest` answer cannot carry `Set-Cookie`.
|
|
@@ -115,8 +164,19 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
|
|
|
115
164
|
this.#maxBodyBytes = optionsArg.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
116
165
|
}
|
|
117
166
|
|
|
118
|
-
/** The paths the routes answer, for registering them one by one. */
|
|
119
|
-
public get paths(): {
|
|
167
|
+
/** The paths the routes answer, for registering them one by one; the `bearer…` paths answer only in bearer mode. */
|
|
168
|
+
public get paths(): {
|
|
169
|
+
login: string;
|
|
170
|
+
callback: string;
|
|
171
|
+
finalize: string;
|
|
172
|
+
session: string;
|
|
173
|
+
logout: string;
|
|
174
|
+
bearerLogin: string;
|
|
175
|
+
bearerClaim: string;
|
|
176
|
+
bearerRefresh: string;
|
|
177
|
+
bearerSession: string;
|
|
178
|
+
bearerLogout: string;
|
|
179
|
+
} {
|
|
120
180
|
const prefix = this.#sessions.loginPathPrefix;
|
|
121
181
|
return {
|
|
122
182
|
login: prefix + 'login',
|
|
@@ -124,6 +184,11 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
|
|
|
124
184
|
finalize: prefix + 'finalize',
|
|
125
185
|
session: prefix + 'session',
|
|
126
186
|
logout: prefix + 'logout',
|
|
187
|
+
bearerLogin: prefix + 'bearer/login',
|
|
188
|
+
bearerClaim: prefix + 'bearer/claim',
|
|
189
|
+
bearerRefresh: prefix + 'bearer/refresh',
|
|
190
|
+
bearerSession: prefix + 'bearer/session',
|
|
191
|
+
bearerLogout: prefix + 'bearer/logout',
|
|
127
192
|
};
|
|
128
193
|
}
|
|
129
194
|
|
|
@@ -133,6 +198,12 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
|
|
|
133
198
|
const method = contextArg.method.toUpperCase();
|
|
134
199
|
const providerLogin = this.#sessions.providerLoginEnabled;
|
|
135
200
|
|
|
201
|
+
if (this.#sessions.bearerLoginEnabled) {
|
|
202
|
+
const bearerResponse = await this.handleBearer(contextArg, path, method);
|
|
203
|
+
if (bearerResponse) {
|
|
204
|
+
return bearerResponse;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
136
207
|
if (path === paths.login && method === 'GET' && providerLogin) {
|
|
137
208
|
return this.login(contextArg);
|
|
138
209
|
}
|
|
@@ -185,6 +256,18 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
|
|
|
185
256
|
private async callback(contextArg: IRelyingPartyHttpContext): Promise<Response> {
|
|
186
257
|
// the callback is read from the public origin, never from a forwarded host
|
|
187
258
|
const callbackUrl = new URL(contextArg.url.pathname + contextArg.url.search, this.#sessions.publicOrigin);
|
|
259
|
+
if (this.#sessions.bearerLoginEnabled) {
|
|
260
|
+
let delivery: IRelyingPartyBearerDelivery | null;
|
|
261
|
+
try {
|
|
262
|
+
delivery = await this.#sessions.bearerDeliveryOf(callbackUrl);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
// unknown whose callback this is: answered as the cookie path answers a failure
|
|
265
|
+
return this.callbackFailure(error, { 'Set-Cookie': this.#sessions.clearIntentCookie() });
|
|
266
|
+
}
|
|
267
|
+
if (delivery) {
|
|
268
|
+
return this.bearerCallback(callbackUrl, delivery);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
188
271
|
const cookieHeader = contextArg.headers.get('cookie');
|
|
189
272
|
const providerError = callbackUrl.searchParams.get('error');
|
|
190
273
|
if (providerError) {
|
|
@@ -211,17 +294,227 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
|
|
|
211
294
|
}
|
|
212
295
|
return jsonResponse({ error: 'authentication_failed' }, error.status, clearIntent);
|
|
213
296
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
297
|
+
return this.callbackFailure(error, clearIntent);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** A callback that failed for a reason other than a refusal: reported when it is the relying party's fault. */
|
|
302
|
+
private callbackFailure(errorArg: unknown, headersArg: Record<string, string>): Response {
|
|
303
|
+
const status = this.callbackFailureStatus(errorArg);
|
|
304
|
+
return jsonResponse({ error: 'authentication_failed' }, status, headersArg);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private callbackFailureStatus(errorArg: unknown): number {
|
|
308
|
+
const status = errorArg instanceof RelyingPartyError ? errorArg.status : 502;
|
|
309
|
+
if (status >= 500) {
|
|
310
|
+
this.emit({
|
|
311
|
+
event: 'auth_callback_failed',
|
|
312
|
+
correlationId: plugins.crypto.randomUUID(),
|
|
313
|
+
stage: errorArg instanceof LoginCallbackError ? errorArg.stage : 'unclassified',
|
|
314
|
+
message: errorArg instanceof Error ? errorArg.message : String(errorArg),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
return status;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ==========================================================================
|
|
321
|
+
// bearer hand-off
|
|
322
|
+
// ==========================================================================
|
|
323
|
+
|
|
324
|
+
private async handleBearer(contextArg: IRelyingPartyHttpContext, pathArg: string, methodArg: string): Promise<Response | null> {
|
|
325
|
+
const paths = this.paths;
|
|
326
|
+
if (pathArg === paths.bearerLogin) {
|
|
327
|
+
return methodArg === 'GET' ? this.bearerLogin(contextArg) : null;
|
|
328
|
+
}
|
|
329
|
+
const route = (['bearerClaim', 'bearerRefresh', 'bearerSession', 'bearerLogout'] as const)
|
|
330
|
+
.find((routeArg: TBearerApiRoute) => paths[routeArg] === pathArg);
|
|
331
|
+
if (!route) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
const corsHeaders = this.bearerCorsHeaders(contextArg);
|
|
335
|
+
if (methodArg === 'OPTIONS') {
|
|
336
|
+
if (!corsHeaders) {
|
|
337
|
+
return jsonResponse({ error: 'forbidden' }, 403, { Vary: 'Origin' });
|
|
338
|
+
}
|
|
339
|
+
return new Response(null, {
|
|
340
|
+
status: 204,
|
|
341
|
+
headers: noStoreHeaders({
|
|
342
|
+
...corsHeaders,
|
|
343
|
+
'Access-Control-Allow-Methods': 'GET, POST',
|
|
344
|
+
'Access-Control-Allow-Headers': 'authorization, content-type',
|
|
345
|
+
'Access-Control-Max-Age': '600',
|
|
346
|
+
}),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
const headers = corsHeaders ?? { Vary: 'Origin' };
|
|
350
|
+
if (route === 'bearerSession') {
|
|
351
|
+
return methodArg === 'GET' ? this.bearerApi(headers, () => this.bearerSessionAnswer(contextArg)) : null;
|
|
352
|
+
}
|
|
353
|
+
if (methodArg !== 'POST') {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
if (route === 'bearerClaim') {
|
|
357
|
+
return this.bearerApi(headers, async () => {
|
|
358
|
+
const body = await this.readJsonBody(contextArg);
|
|
359
|
+
return this.bearerTokenAnswer(
|
|
360
|
+
await this.#sessions.claimBearerHandoff(bodyField(body, 'code'), bodyField(body, 'codeVerifier')),
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
if (route === 'bearerRefresh') {
|
|
365
|
+
return this.bearerApi(headers, async () => {
|
|
366
|
+
const body = await this.readJsonBody(contextArg);
|
|
367
|
+
return this.bearerTokenAnswer(await this.#sessions.refreshBearerTokens(bodyField(body, 'refreshToken')));
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
return this.bearerApi(headers, async () => {
|
|
371
|
+
const accessToken = parseBearerAuthorization(contextArg.headers.get('authorization'));
|
|
372
|
+
let endedLiveSession = accessToken ? await this.#sessions.endBearerSession(accessToken) : false;
|
|
373
|
+
if (!endedLiveSession) {
|
|
374
|
+
const refreshToken = bodyField(await this.readJsonBody(contextArg), 'refreshToken');
|
|
375
|
+
endedLiveSession = refreshToken !== undefined ? await this.#sessions.endBearerSession(refreshToken) : false;
|
|
222
376
|
}
|
|
223
|
-
return
|
|
377
|
+
return { endedLiveSession };
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The CORS answer for a request from a popup return target's origin, which
|
|
383
|
+
* is matched exactly; `null` for every other origin, which gets none.
|
|
384
|
+
* Credentials are never allowed: bearer mode carries no cookie.
|
|
385
|
+
*/
|
|
386
|
+
private bearerCorsHeaders(contextArg: IRelyingPartyHttpContext): Record<string, string> | null {
|
|
387
|
+
const origin = contextArg.headers.get('origin');
|
|
388
|
+
const popupOrigins = this.#sessions.bearerReturnTargets?.popup ?? [];
|
|
389
|
+
if (origin === null || !popupOrigins.includes(origin)) {
|
|
390
|
+
return null;
|
|
224
391
|
}
|
|
392
|
+
return { 'Access-Control-Allow-Origin': origin, Vary: 'Origin' };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private async bearerLogin(contextArg: IRelyingPartyHttpContext): Promise<Response> {
|
|
396
|
+
const query = contextArg.url.searchParams;
|
|
397
|
+
const state = singleParam(query, 'state');
|
|
398
|
+
// every field is checked by startBearerLogin, which takes them as untrusted input
|
|
399
|
+
const request = {
|
|
400
|
+
clientKind: singleParam(query, 'client_kind'),
|
|
401
|
+
returnTarget: singleParam(query, 'return_target'),
|
|
402
|
+
codeChallenge: singleParam(query, 'code_challenge'),
|
|
403
|
+
codeChallengeMethod: singleParam(query, 'code_challenge_method'),
|
|
404
|
+
...(state !== undefined ? { state } : {}),
|
|
405
|
+
} as unknown as IRelyingPartyBearerLoginRequest;
|
|
406
|
+
try {
|
|
407
|
+
const start = await this.#sessions.startBearerLogin(request);
|
|
408
|
+
return redirect(start.authorizationUrl.href, { 'Referrer-Policy': 'no-referrer' });
|
|
409
|
+
} catch (error) {
|
|
410
|
+
if (error instanceof RelyingPartyError && error.status < 500) {
|
|
411
|
+
this.emit({ event: 'auth_bearer_start_refused', code: error.code });
|
|
412
|
+
return jsonResponse({ error: error.code }, error.status);
|
|
413
|
+
}
|
|
414
|
+
this.emit({ event: 'auth_login_start_failed', message: error instanceof Error ? error.message : String(error) });
|
|
415
|
+
return jsonResponse({ error: 'authentication_unavailable' }, 503);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The callback of a bearer login, delivered to its return target whatever
|
|
421
|
+
* the outcome: the hand-off code, `access_denied` when the provider came
|
|
422
|
+
* back without one, the code of an application refusal (its `location` is
|
|
423
|
+
* a page of the cookie flow and not followed), or `authentication_failed`.
|
|
424
|
+
*/
|
|
425
|
+
private async bearerCallback(callbackUrlArg: URL, deliveryArg: IRelyingPartyBearerDelivery): Promise<Response> {
|
|
426
|
+
const providerError = callbackUrlArg.searchParams.get('error');
|
|
427
|
+
if (providerError) {
|
|
428
|
+
await this.#sessions.abandonBearerLogin(callbackUrlArg);
|
|
429
|
+
this.emit({ event: 'auth_callback_declined', code: providerError.slice(0, MAX_PROVIDER_ERROR_CODE_LENGTH) });
|
|
430
|
+
return this.deliverBearerOutcome(deliveryArg, { error: 'access_denied' });
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
const handoff = await this.#sessions.completeBearerLogin(callbackUrlArg);
|
|
434
|
+
return this.deliverBearerOutcome(handoff.delivery, { code: handoff.code });
|
|
435
|
+
} catch (error) {
|
|
436
|
+
if (error instanceof RelyingPartyRefusal) {
|
|
437
|
+
this.emit({ event: 'auth_callback_refused', code: error.code });
|
|
438
|
+
return this.deliverBearerOutcome(deliveryArg, { error: error.code });
|
|
439
|
+
}
|
|
440
|
+
this.callbackFailureStatus(error);
|
|
441
|
+
return this.deliverBearerOutcome(deliveryArg, { error: 'authentication_failed' });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* A redirect to the return target with the outcome in its query, or for a
|
|
447
|
+
* popup the page that posts it to the opener. Neither sends a referrer, and
|
|
448
|
+
* the popup page runs its one nonce script and nothing else.
|
|
449
|
+
*/
|
|
450
|
+
private deliverBearerOutcome(deliveryArg: IRelyingPartyBearerDelivery, outcomeArg: TRelyingPartyBearerOutcome): Response {
|
|
451
|
+
if (deliveryArg.clientKind !== 'popup') {
|
|
452
|
+
return redirect(
|
|
453
|
+
bearerDeliveryLocation(deliveryArg, outcomeArg, this.#sessions.publicOrigin),
|
|
454
|
+
{ 'Referrer-Policy': 'no-referrer' },
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
const nonce = randomToken(16);
|
|
458
|
+
return new Response(bearerPopupPage(deliveryArg, outcomeArg, nonce), {
|
|
459
|
+
status: 200,
|
|
460
|
+
headers: noStoreHeaders({
|
|
461
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
462
|
+
'Content-Security-Policy': bearerPopupContentSecurityPolicy(nonce),
|
|
463
|
+
'X-Content-Type-Options': 'nosniff',
|
|
464
|
+
'Referrer-Policy': 'no-referrer',
|
|
465
|
+
}),
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Runs one bearer API step and answers its JSON. A refused credential
|
|
471
|
+
* answers its code with 400; an unreachable provider answers
|
|
472
|
+
* `authority_unavailable` with 503 and spends nothing; any other failure is
|
|
473
|
+
* reported and answers `authentication_unavailable`.
|
|
474
|
+
*/
|
|
475
|
+
private async bearerApi(headersArg: Record<string, string>, stepArg: () => Promise<object>): Promise<Response> {
|
|
476
|
+
try {
|
|
477
|
+
return jsonResponse(await stepArg(), 200, headersArg);
|
|
478
|
+
} catch (error) {
|
|
479
|
+
if (error instanceof RelyingPartyError && error.status < 500) {
|
|
480
|
+
return jsonResponse({ error: error.code }, error.status, headersArg);
|
|
481
|
+
}
|
|
482
|
+
if (error instanceof AuthorityTokenError && error.code === 'authority_unavailable') {
|
|
483
|
+
return jsonResponse({ error: error.code }, error.status, headersArg);
|
|
484
|
+
}
|
|
485
|
+
this.emit({
|
|
486
|
+
event: 'auth_bearer_failed',
|
|
487
|
+
correlationId: plugins.crypto.randomUUID(),
|
|
488
|
+
message: error instanceof Error ? error.message : String(error),
|
|
489
|
+
});
|
|
490
|
+
return jsonResponse({ error: 'authentication_unavailable' }, 503, headersArg);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private bearerTokenAnswer(tokensArg: IRelyingPartyBearerTokens): object {
|
|
495
|
+
return {
|
|
496
|
+
tokenType: 'Bearer',
|
|
497
|
+
accessToken: tokensArg.accessToken,
|
|
498
|
+
accessTokenExpiresAt: tokensArg.accessTokenExpiresAt,
|
|
499
|
+
// counted on the relying party's clock, so a client with another clock can use it
|
|
500
|
+
expiresIn: Math.max(0, Math.floor((tokensArg.accessTokenExpiresAt - tokensArg.issuedAt) / 1_000)),
|
|
501
|
+
refreshToken: tokensArg.refreshToken,
|
|
502
|
+
refreshTokenExpiresAt: tokensArg.refreshTokenExpiresAt,
|
|
503
|
+
session: {
|
|
504
|
+
sessionId: tokensArg.session.id,
|
|
505
|
+
principalId: tokensArg.session.principalId,
|
|
506
|
+
expiresAt: tokensArg.session.expiresAt,
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private async bearerSessionAnswer(contextArg: IRelyingPartyHttpContext): Promise<object> {
|
|
512
|
+
const live = await this.#sessions.resolveBearerSession(contextArg.headers.get('authorization'));
|
|
513
|
+
return {
|
|
514
|
+
session: live
|
|
515
|
+
? { sessionId: live.session.id, principalId: live.session.principalId, expiresAt: live.session.expiresAt }
|
|
516
|
+
: null,
|
|
517
|
+
};
|
|
225
518
|
}
|
|
226
519
|
|
|
227
520
|
private async logout(contextArg: IRelyingPartyHttpContext): Promise<Response> {
|