@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 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:
@@ -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 {};
@@ -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
- const expiresAt = data.tokens.accessTokenExpiresAt;
768
- const sessionExpiresIn = expiresAt !== undefined
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
- return c.json({
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 = this.#postLogoutRedirect(client, params);
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's authorization answers: what
3
- * introspection reports about a signed-in person, what `GET /api/memberships`
4
- * lists for them, and what `POST /api/check` and `POST /api/check/batch`
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 with and
39
- * refreshes with. It must be allowed the `refresh_token` grant.
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
- /** Adds a person holding `permissions` tenant-wide, and returns their id. */
51
- addUser(permissions: string[]): Promise<string>;
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. */