@udibo/oauth2 0.7.0 → 0.9.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/README.md +5 -0
- package/esm/_dnt.polyfills.d.ts +14 -0
- package/esm/_dnt.polyfills.js +11 -0
- package/esm/adapters/hono/bff/bff.d.ts +43 -1
- package/esm/adapters/hono/bff/bff.js +67 -9
- package/esm/server/authorization-server.d.ts +11 -1
- package/esm/server/authorization-server.js +4 -2
- package/esm/testing/contract/mod.d.ts +1 -1
- package/esm/testing/contract/tenant.d.ts +49 -9
- package/esm/testing/contract/tenant.js +631 -5
- package/esm/testing/mod.d.ts +6 -5
- package/esm/testing/mod.js +5 -4
- package/esm/testing/tenant.d.ts +88 -16
- package/esm/testing/tenant.js +971 -57
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -76,6 +76,11 @@ a browser app with a backend, start with the BFF guide.
|
|
|
76
76
|
authorization server. The framework-independent core accepts standard `Request`
|
|
77
77
|
and `Response` objects; Hono adapters add routing and middleware.
|
|
78
78
|
|
|
79
|
+
For app-owned login, `CaptchaProvider` and `verifyCaptcha` let your route check
|
|
80
|
+
a client-issued bot-challenge token before calling `IdentityService`. The
|
|
81
|
+
package does not include a challenge provider; see the
|
|
82
|
+
[CAPTCHA route example](docs/guides/add-login.md#captcha-at-the-route-boundary).
|
|
83
|
+
|
|
79
84
|
## Examples
|
|
80
85
|
|
|
81
86
|
From a checkout of this repository:
|
package/esm/_dnt.polyfills.d.ts
CHANGED
|
@@ -15,3 +15,17 @@ declare global {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
export {};
|
|
18
|
+
declare global {
|
|
19
|
+
interface PromiseConstructor {
|
|
20
|
+
/**
|
|
21
|
+
* Creates a Promise that can be resolved or rejected using provided functions.
|
|
22
|
+
* @returns An object containing `promise` promise object, `resolve` and `reject` functions.
|
|
23
|
+
*/
|
|
24
|
+
withResolvers<T>(): {
|
|
25
|
+
promise: Promise<T>;
|
|
26
|
+
resolve: (value: T | PromiseLike<T>) => void;
|
|
27
|
+
reject: (reason?: any) => void;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export {};
|
package/esm/_dnt.polyfills.js
CHANGED
|
@@ -12,4 +12,15 @@ if (!Object.hasOwn) {
|
|
|
12
12
|
writable: true,
|
|
13
13
|
});
|
|
14
14
|
}
|
|
15
|
+
// https://github.com/tc39/proposal-promise-with-resolvers/blob/3a78801e073e99217dbeb2c43ba7212f3bdc8b83/polyfills.js#L1C1-L9C2
|
|
16
|
+
if (Promise.withResolvers === undefined) {
|
|
17
|
+
Promise.withResolvers = () => {
|
|
18
|
+
const out = {};
|
|
19
|
+
out.promise = new Promise((resolve_, reject_) => {
|
|
20
|
+
out.resolve = resolve_;
|
|
21
|
+
out.reject = reject_;
|
|
22
|
+
});
|
|
23
|
+
return out;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
15
26
|
export {};
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
*/
|
|
50
50
|
import { Hono } from "hono";
|
|
51
51
|
import type { Context, Handler, MiddlewareHandler } from "hono";
|
|
52
|
-
import type { DirectClient, TokenBundle, UserInfoClaims } from "../../../client/mod.js";
|
|
52
|
+
import type { DirectClient, SessionState, TokenBundle, UserInfoClaims } from "../../../client/mod.js";
|
|
53
53
|
import type { ClientInterface } from "../../../models/client.js";
|
|
54
54
|
import type { AbstractScope, BasicScope } from "../../../models/scope.js";
|
|
55
55
|
import type { HonoAuthorizationServer } from "../authorization-server.js";
|
|
@@ -731,6 +731,48 @@ export declare class HonoBff {
|
|
|
731
731
|
* `logoutUrl` when signed in. Never returns tokens.
|
|
732
732
|
*/
|
|
733
733
|
sessionHandler(): Handler;
|
|
734
|
+
/**
|
|
735
|
+
* Who is signed in on this request, for a server that renders pages: the
|
|
736
|
+
* answer `GET /auth/session` gives the browser, read without a round trip to
|
|
737
|
+
* it. Seed the React provider's `initialState` with it so a signed-in page
|
|
738
|
+
* renders signed in on the first paint.
|
|
739
|
+
*
|
|
740
|
+
* A session counts exactly when the probe would count it. In `sessionMode:
|
|
741
|
+
* "own"`, a session older than `sessionMaxAgeMs` is destroyed and its cookie
|
|
742
|
+
* cleared on this response, just as the probe does. Shared mode leaves
|
|
743
|
+
* lifetime enforcement to the application and its session store.
|
|
744
|
+
* It never returns a token, and it does
|
|
745
|
+
* not refresh one: `sessionExpiresIn` is `0` once the access token has
|
|
746
|
+
* expired, and the next `attachToken` or `protect` refreshes it when the
|
|
747
|
+
* session holds a refresh token.
|
|
748
|
+
*
|
|
749
|
+
* It skips the CSRF header check, because the answer goes to your server,
|
|
750
|
+
* not to a page that could read it cross-site. Call it only on the server.
|
|
751
|
+
* It sets no caching policy. A response built from its answer is one
|
|
752
|
+
* person's, so send it with a private or `no-store` `Cache-Control`.
|
|
753
|
+
*
|
|
754
|
+
* @param c The request's Hono context. The session cookie is read from it.
|
|
755
|
+
* @returns The same fields as `BffClient.getSession()`. Signed out,
|
|
756
|
+
* `user`, `sessionExpiresIn` and `logoutUrl` are all `null`.
|
|
757
|
+
*
|
|
758
|
+
* @example Seed the React provider from a server-rendered page
|
|
759
|
+
* ```ts
|
|
760
|
+
* import { Hono } from "hono";
|
|
761
|
+
* import type { SessionState } from "@udibo/oauth2/client";
|
|
762
|
+
* import type { HonoBff } from "@udibo/oauth2/hono/bff";
|
|
763
|
+
*
|
|
764
|
+
* declare const bff: HonoBff;
|
|
765
|
+
* declare function renderPage(initialState: SessionState): string;
|
|
766
|
+
*
|
|
767
|
+
* const app = new Hono();
|
|
768
|
+
* app.route("/auth", bff.routes());
|
|
769
|
+
* app.get("/", async (c) => {
|
|
770
|
+
* c.header("Cache-Control", "private, no-store");
|
|
771
|
+
* return c.html(renderPage(await bff.readSession(c)));
|
|
772
|
+
* });
|
|
773
|
+
* ```
|
|
774
|
+
*/
|
|
775
|
+
readSession(c: Context): Promise<SessionState>;
|
|
734
776
|
/**
|
|
735
777
|
* OIDC Back-Channel Logout receiver. A server-to-server `POST` from the OP
|
|
736
778
|
* carrying a signed `logout_token`; the configured verifier validates it (and
|
|
@@ -764,16 +764,74 @@ export class HonoBff {
|
|
|
764
764
|
if (!data) {
|
|
765
765
|
return c.json({ isAuthenticated: false, user: null });
|
|
766
766
|
}
|
|
767
|
-
|
|
768
|
-
|
|
767
|
+
return c.json(this.#signedInState(data));
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Who is signed in on this request, for a server that renders pages: the
|
|
772
|
+
* answer `GET /auth/session` gives the browser, read without a round trip to
|
|
773
|
+
* it. Seed the React provider's `initialState` with it so a signed-in page
|
|
774
|
+
* renders signed in on the first paint.
|
|
775
|
+
*
|
|
776
|
+
* A session counts exactly when the probe would count it. In `sessionMode:
|
|
777
|
+
* "own"`, a session older than `sessionMaxAgeMs` is destroyed and its cookie
|
|
778
|
+
* cleared on this response, just as the probe does. Shared mode leaves
|
|
779
|
+
* lifetime enforcement to the application and its session store.
|
|
780
|
+
* It never returns a token, and it does
|
|
781
|
+
* not refresh one: `sessionExpiresIn` is `0` once the access token has
|
|
782
|
+
* expired, and the next `attachToken` or `protect` refreshes it when the
|
|
783
|
+
* session holds a refresh token.
|
|
784
|
+
*
|
|
785
|
+
* It skips the CSRF header check, because the answer goes to your server,
|
|
786
|
+
* not to a page that could read it cross-site. Call it only on the server.
|
|
787
|
+
* It sets no caching policy. A response built from its answer is one
|
|
788
|
+
* person's, so send it with a private or `no-store` `Cache-Control`.
|
|
789
|
+
*
|
|
790
|
+
* @param c The request's Hono context. The session cookie is read from it.
|
|
791
|
+
* @returns The same fields as `BffClient.getSession()`. Signed out,
|
|
792
|
+
* `user`, `sessionExpiresIn` and `logoutUrl` are all `null`.
|
|
793
|
+
*
|
|
794
|
+
* @example Seed the React provider from a server-rendered page
|
|
795
|
+
* ```ts
|
|
796
|
+
* import { Hono } from "hono";
|
|
797
|
+
* import type { SessionState } from "@udibo/oauth2/client";
|
|
798
|
+
* import type { HonoBff } from "@udibo/oauth2/hono/bff";
|
|
799
|
+
*
|
|
800
|
+
* declare const bff: HonoBff;
|
|
801
|
+
* declare function renderPage(initialState: SessionState): string;
|
|
802
|
+
*
|
|
803
|
+
* const app = new Hono();
|
|
804
|
+
* app.route("/auth", bff.routes());
|
|
805
|
+
* app.get("/", async (c) => {
|
|
806
|
+
* c.header("Cache-Control", "private, no-store");
|
|
807
|
+
* return c.html(renderPage(await bff.readSession(c)));
|
|
808
|
+
* });
|
|
809
|
+
* ```
|
|
810
|
+
*/
|
|
811
|
+
async readSession(c) {
|
|
812
|
+
const cookieValue = getCookie(c, this.#cookieName);
|
|
813
|
+
const data = cookieValue
|
|
814
|
+
? await this.#readLiveSession(c, cookieValue)
|
|
815
|
+
: null;
|
|
816
|
+
if (!data) {
|
|
817
|
+
return {
|
|
818
|
+
isAuthenticated: false,
|
|
819
|
+
user: null,
|
|
820
|
+
sessionExpiresIn: null,
|
|
821
|
+
logoutUrl: null,
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
return this.#signedInState(data);
|
|
825
|
+
}
|
|
826
|
+
#signedInState(data) {
|
|
827
|
+
const expiresAt = data.tokens.accessTokenExpiresAt;
|
|
828
|
+
return {
|
|
829
|
+
isAuthenticated: true,
|
|
830
|
+
user: data.user ?? null,
|
|
831
|
+
sessionExpiresIn: expiresAt !== undefined
|
|
769
832
|
? Math.max(0, Math.round((expiresAt - Date.now()) / 1000))
|
|
770
|
-
: null
|
|
771
|
-
|
|
772
|
-
isAuthenticated: true,
|
|
773
|
-
user: data.user ?? null,
|
|
774
|
-
sessionExpiresIn,
|
|
775
|
-
logoutUrl: this.#paths.logout,
|
|
776
|
-
});
|
|
833
|
+
: null,
|
|
834
|
+
logoutUrl: this.#paths.logout,
|
|
777
835
|
};
|
|
778
836
|
}
|
|
779
837
|
/**
|
|
@@ -63,9 +63,19 @@ export interface EndSessionContext<Client extends ClientInterface> {
|
|
|
63
63
|
}
|
|
64
64
|
/** What an app's {@link EndSessionFn} may ask the server to do afterwards. */
|
|
65
65
|
export interface EndSessionResult {
|
|
66
|
+
/**
|
|
67
|
+
* The app refused this logout (for example, the verified `id_token_hint`
|
|
68
|
+
* names a different person than the current session). Ignore the caller's
|
|
69
|
+
* redirect and all `headers`, including any `Set-Cookie`, so the response
|
|
70
|
+
* neither sends the browser to the RP nor clears the current session.
|
|
71
|
+
* Answer at `fallback` if supplied, otherwise 204. Omit or set `false` to
|
|
72
|
+
* preserve the normal logout behavior.
|
|
73
|
+
*/
|
|
74
|
+
refused?: boolean;
|
|
66
75
|
/**
|
|
67
76
|
* Headers to merge into the response — the session-clearing `Set-Cookie`,
|
|
68
|
-
* typically. The server owns `Location`; anything else is yours.
|
|
77
|
+
* typically. The server owns `Location`; anything else is yours. Ignored
|
|
78
|
+
* when `refused` is true.
|
|
69
79
|
*/
|
|
70
80
|
headers?: HeadersInit;
|
|
71
81
|
/**
|
|
@@ -580,8 +580,10 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
580
580
|
subject: params.subject,
|
|
581
581
|
logoutHint: params.logoutHint,
|
|
582
582
|
}) ?? {};
|
|
583
|
-
const headers = new Headers(result.headers);
|
|
584
|
-
const authorized =
|
|
583
|
+
const headers = new Headers(result.refused ? undefined : result.headers);
|
|
584
|
+
const authorized = result.refused
|
|
585
|
+
? undefined
|
|
586
|
+
: this.#postLogoutRedirect(client, params);
|
|
585
587
|
const target = authorized ?? result.fallback;
|
|
586
588
|
if (!target)
|
|
587
589
|
return new Response(null, { status: 204, headers });
|
|
@@ -39,4 +39,4 @@ export { type OtpStoreContractOptions, runOtpStoreContractTests, } from "./otp-s
|
|
|
39
39
|
export { type RateLimitStoreContractOptions, runRateLimitStoreContractTests, } from "./rate-limit-store.js";
|
|
40
40
|
export { type LockoutStoreContractOptions, runLockoutStoreContractTests, } from "./lockout-store.js";
|
|
41
41
|
export { type AuthRequestStorageContractOptions, runAuthRequestStorageContractTests, } from "./auth-request-storage.js";
|
|
42
|
-
export { runTenantContractTests, type TenantContractFixture, type TenantContractOptions, type TenantContractTokens, } from "./tenant.js";
|
|
42
|
+
export { runTenantContractTests, type TenantContractFixture, type TenantContractOptions, type TenantContractProfile, type TenantContractSignInOptions, type TenantContractTokens, } from "./tenant.js";
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Contract test suite for a Udibo Identity tenant
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* answer
|
|
2
|
+
* Contract test suite for what a Udibo Identity tenant answers an application
|
|
3
|
+
* about its signed-in people: what introspection reports, what
|
|
4
|
+
* `GET /api/memberships` lists, what `POST /api/check` and
|
|
5
|
+
* `POST /api/check/batch` answer, the organization API under
|
|
6
|
+
* `/api/organizations`, and the account API under `/api/account`.
|
|
6
7
|
*
|
|
7
8
|
* It runs against `createFakeTenant` from `@udibo/oauth2/testing` and against the
|
|
8
9
|
* real identity service in Udibo's own repository, so the fake an app tests
|
|
@@ -35,8 +36,9 @@ export interface TenantContractFixture {
|
|
|
35
36
|
/** The tenant's origin; its RFC 8414 metadata is served beneath it. */
|
|
36
37
|
issuer: string;
|
|
37
38
|
/**
|
|
38
|
-
* A confidential client the suite signs in with, introspects
|
|
39
|
-
* refreshes with. It must be allowed the
|
|
39
|
+
* A confidential first-party client the suite signs in with, introspects
|
|
40
|
+
* with, refreshes with and revokes with. It must be allowed the
|
|
41
|
+
* `refresh_token` grant.
|
|
40
42
|
*/
|
|
41
43
|
client: {
|
|
42
44
|
id: string;
|
|
@@ -47,8 +49,19 @@ export interface TenantContractFixture {
|
|
|
47
49
|
* one when the tenant's host resolves only through a proxy.
|
|
48
50
|
*/
|
|
49
51
|
fetch?: typeof fetch;
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Adds a person holding `permissions` tenant-wide, and returns their id. The
|
|
54
|
+
* organization and account checks pass a `profile`; the others do not.
|
|
55
|
+
*/
|
|
56
|
+
addUser(permissions: string[], profile?: TenantContractProfile): Promise<string>;
|
|
57
|
+
/**
|
|
58
|
+
* Links an external sign-in account to a person and returns the linked
|
|
59
|
+
* account's id.
|
|
60
|
+
*/
|
|
61
|
+
linkAccount(userId: string, account: {
|
|
62
|
+
provider: string;
|
|
63
|
+
email: string | null;
|
|
64
|
+
}): Promise<string>;
|
|
52
65
|
/** Adds an organization and returns its id. */
|
|
53
66
|
addOrganization(): Promise<string>;
|
|
54
67
|
/**
|
|
@@ -77,11 +90,38 @@ export interface TenantContractFixture {
|
|
|
77
90
|
* Signs the person in through the authorization-code flow and returns the
|
|
78
91
|
* tokens it issued. With an organization named, the credential is issued in
|
|
79
92
|
* it; with none, the sign-in picks nothing, leaving the tenant to decide.
|
|
93
|
+
* Each sign-in comes from a new browser and so starts a login session of its
|
|
94
|
+
* own, unless `options.sameBrowser` says otherwise.
|
|
80
95
|
*/
|
|
81
|
-
signIn(userId: string, organizationId?: string): Promise<TenantContractTokens>;
|
|
96
|
+
signIn(userId: string, organizationId?: string, options?: TenantContractSignInOptions): Promise<TenantContractTokens>;
|
|
82
97
|
/** Releases anything `setup` allocated. */
|
|
83
98
|
cleanup?(): Promise<void> | void;
|
|
84
99
|
}
|
|
100
|
+
/** How `TenantContractFixture.signIn` signs a person in. */
|
|
101
|
+
export interface TenantContractSignInOptions {
|
|
102
|
+
/**
|
|
103
|
+
* Sign in from the browser this person's previous sign-in used, so the
|
|
104
|
+
* tenant continues that login session instead of starting another. The
|
|
105
|
+
* suite asks for it only straight after that previous sign-in.
|
|
106
|
+
*/
|
|
107
|
+
sameBrowser?: boolean;
|
|
108
|
+
}
|
|
109
|
+
/** Who a person added with `TenantContractFixture.addUser` is. */
|
|
110
|
+
export interface TenantContractProfile {
|
|
111
|
+
/**
|
|
112
|
+
* The address the person holds. When it is omitted, the fixture may give them
|
|
113
|
+
* any address or none.
|
|
114
|
+
*/
|
|
115
|
+
email?: string;
|
|
116
|
+
/** Whether that address is verified. Defaults to `true`. */
|
|
117
|
+
emailVerified?: boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Whether the person has a password. Defaults to `true`. With `false`, the
|
|
120
|
+
* fixture must still be able to sign them in, the way a tenant signs such a
|
|
121
|
+
* person in through a linked account.
|
|
122
|
+
*/
|
|
123
|
+
password?: boolean;
|
|
124
|
+
}
|
|
85
125
|
/** Options for {@link runTenantContractTests}. */
|
|
86
126
|
export interface TenantContractOptions {
|
|
87
127
|
/** Overrides the top-level `describe` name. */
|