@grest-ts/oauth 0.0.0-stage → 0.0.90

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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +165 -2
  3. package/dist/src/_dedupCheck.d.ts +2 -0
  4. package/dist/src/_dedupCheck.d.ts.map +1 -0
  5. package/dist/src/_dedupCheck.js +28 -0
  6. package/dist/src/_dedupCheck.js.map +1 -0
  7. package/dist/src/browser/GGOAuthClient.d.ts +50 -0
  8. package/dist/src/browser/GGOAuthClient.d.ts.map +1 -0
  9. package/dist/src/browser/GGOAuthClient.js +152 -0
  10. package/dist/src/browser/GGOAuthClient.js.map +1 -0
  11. package/dist/src/index-browser.d.ts +6 -0
  12. package/dist/src/index-browser.d.ts.map +1 -0
  13. package/dist/src/index-browser.js +4 -0
  14. package/dist/src/index-browser.js.map +1 -0
  15. package/dist/src/index-node.d.ts +8 -0
  16. package/dist/src/index-node.d.ts.map +1 -0
  17. package/dist/src/index-node.js +5 -0
  18. package/dist/src/index-node.js.map +1 -0
  19. package/dist/src/node/GGOAuthServer.d.ts +72 -0
  20. package/dist/src/node/GGOAuthServer.d.ts.map +1 -0
  21. package/dist/src/node/GGOAuthServer.js +430 -0
  22. package/dist/src/node/GGOAuthServer.js.map +1 -0
  23. package/dist/src/node/store/DynamoOAuthStore.d.ts +28 -0
  24. package/dist/src/node/store/DynamoOAuthStore.d.ts.map +1 -0
  25. package/dist/src/node/store/DynamoOAuthStore.js +78 -0
  26. package/dist/src/node/store/DynamoOAuthStore.js.map +1 -0
  27. package/dist/src/node/store/GGOAuthStore.d.ts +18 -0
  28. package/dist/src/node/store/GGOAuthStore.d.ts.map +1 -0
  29. package/dist/src/node/store/GGOAuthStore.js +16 -0
  30. package/dist/src/node/store/GGOAuthStore.js.map +1 -0
  31. package/dist/src/node/store/InMemoryOAuthStore.d.ts +16 -0
  32. package/dist/src/node/store/InMemoryOAuthStore.d.ts.map +1 -0
  33. package/dist/src/node/store/InMemoryOAuthStore.js +44 -0
  34. package/dist/src/node/store/InMemoryOAuthStore.js.map +1 -0
  35. package/dist/src/shared/interactionContract.d.ts +77 -0
  36. package/dist/src/shared/interactionContract.d.ts.map +1 -0
  37. package/dist/src/shared/interactionContract.js +34 -0
  38. package/dist/src/shared/interactionContract.js.map +1 -0
  39. package/dist/src/tsconfig.json +18 -0
  40. package/dist/tsconfig.publish.tsbuildinfo +1 -0
  41. package/package.json +71 -4
  42. package/src/_dedupCheck.ts +29 -0
  43. package/src/browser/GGOAuthClient.test.ts +76 -0
  44. package/src/browser/GGOAuthClient.ts +187 -0
  45. package/src/index-browser.ts +5 -0
  46. package/src/index-node.ts +7 -0
  47. package/src/node/GGOAuthServer.test.ts +416 -0
  48. package/src/node/GGOAuthServer.ts +500 -0
  49. package/src/node/store/DynamoOAuthStore.ts +93 -0
  50. package/src/node/store/GGOAuthStore.ts +41 -0
  51. package/src/node/store/InMemoryOAuthStore.ts +54 -0
  52. package/src/shared/interactionContract.ts +39 -0
  53. package/src/tsconfig.json +18 -0
package/package.json CHANGED
@@ -1,6 +1,73 @@
1
1
  {
2
2
  "name": "@grest-ts/oauth",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.90",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "description": "OAuth 2.1 authorization server (panva oidc-provider on GGHttpServer) issuing @grest-ts/auth tokens, plus a browser code+PKCE client for GGAuthSession",
7
+ "exports": {
8
+ ".": {
9
+ "browser": {
10
+ "types": "./dist/src/index-browser.d.ts",
11
+ "import": "./dist/src/index-browser.js"
12
+ },
13
+ "default": {
14
+ "types": "./dist/src/index-node.d.ts",
15
+ "import": "./dist/src/index-node.js"
16
+ }
17
+ },
18
+ "./dynamodb": {
19
+ "types": "./dist/src/node/store/DynamoOAuthStore.d.ts",
20
+ "import": "./dist/src/node/store/DynamoOAuthStore.js"
21
+ }
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "src"
26
+ ],
27
+ "scripts": {
28
+ "typecheck": "tsc --noEmit -p src",
29
+ "test": "vitest run",
30
+ "test:coverage": "vitest run --coverage",
31
+ "test:coverage:html": "vitest run --coverage --reporter=html"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/grest-ts/grest-ts.git",
39
+ "directory": "packages-libs/auth/oauth"
40
+ },
41
+ "homepage": "https://grest-ts.com/packages/oauth",
42
+ "bugs": {
43
+ "url": "https://github.com/grest-ts/grest-ts/issues"
44
+ },
45
+ "keywords": [
46
+ "typescript",
47
+ "framework",
48
+ "contract",
49
+ "api",
50
+ "microservices",
51
+ "testing",
52
+ "auth",
53
+ "oauth",
54
+ "oauth2",
55
+ "oidc",
56
+ "pkce",
57
+ "mcp"
58
+ ],
59
+ "engines": {
60
+ "node": ">=24"
61
+ },
62
+ "dependencies": {
63
+ "@types/oidc-provider": "9.12.1",
64
+ "jose": "^6.2.12",
65
+ "oidc-provider": "9.13.0"
66
+ },
67
+ "peerDependencies": {
68
+ "@grest-ts/auth": "0.0.90",
69
+ "@grest-ts/db-dynamodb": "0.0.90",
70
+ "@grest-ts/http": "0.0.90",
71
+ "@grest-ts/schema": "0.0.90"
72
+ }
73
+ }
@@ -0,0 +1,29 @@
1
+ // =============================================================================
2
+ // THIS FILE IS GENERATED - DO NOT EDIT
3
+ //
4
+ // Detects duplicate loads of @grest-ts/oauth. Two copies of a grest-ts
5
+ // framework package in the same process break async context, validation
6
+ // registries, and service discovery. Throw with both paths so the packaging
7
+ // mistake is immediately diagnosable.
8
+ //
9
+ // Generated by scripts/packager — re-run `npm run generate` to refresh.
10
+ // =============================================================================
11
+
12
+ const __KEY = Symbol.for("grest-ts:dedup:@grest-ts/oauth");
13
+ const __slot = globalThis as Record<symbol, unknown>;
14
+ const __existing = __slot[__KEY] as { path: string; version: string } | undefined;
15
+ const __here = { path: import.meta.url, version: "0.0.90" };
16
+ // Same path + same version means the same physical package is being
17
+ // re-evaluated by a different ESM runner (e.g. vitest projects share one
18
+ // process but each project has its own module cache). That isn't a true
19
+ // duplicate package — only differing paths or versions are.
20
+ if (__existing && (__existing.path !== __here.path || __existing.version !== __here.version)) {
21
+ throw new Error(
22
+ "[@grest-ts/oauth] Duplicate package load detected.\n" +
23
+ " Already loaded: " + __existing.path + " (v" + __existing.version + ")\n" +
24
+ " Now loading: " + __here.path + " (v" + __here.version + ")\n" +
25
+ "Two copies of grest-ts framework packages in one process break\n" +
26
+ "async context, validation, and service discovery. Fix the packaging."
27
+ );
28
+ }
29
+ __slot[__KEY] = __here;
@@ -0,0 +1,76 @@
1
+ import {afterEach, beforeEach, describe, expect, test, vi} from "vitest"
2
+ import {GGOAuthClient} from "./GGOAuthClient"
3
+
4
+ const SERVER = "https://auth.example"
5
+ const METADATA = {
6
+ issuer: SERVER,
7
+ authorization_endpoint: `${SERVER}/oauth/authorize`,
8
+ token_endpoint: `${SERVER}/oauth/token`,
9
+ revocation_endpoint: `${SERVER}/oauth/revoke`,
10
+ }
11
+
12
+ describe("GGOAuthClient redirects", () => {
13
+ let assign: ReturnType<typeof vi.fn>
14
+ let fetchMock: ReturnType<typeof vi.fn>
15
+ let metadata: Record<string, string>
16
+
17
+ beforeEach(() => {
18
+ assign = vi.fn()
19
+ metadata = {...METADATA}
20
+ fetchMock = vi.fn(async (url: string) => {
21
+ if (url.endsWith("/.well-known/oauth-authorization-server")) return new Response(JSON.stringify(metadata))
22
+ return new Response("")
23
+ })
24
+ vi.stubGlobal("window", {location: {assign}, addEventListener: vi.fn()})
25
+ vi.stubGlobal("sessionStorage", {setItem: vi.fn(), getItem: vi.fn(), removeItem: vi.fn()})
26
+ vi.stubGlobal("fetch", fetchMock)
27
+ })
28
+ afterEach(() => vi.unstubAllGlobals())
29
+
30
+ const client = () => new GGOAuthClient({server: SERVER, clientId: "web", redirectUri: "https://app.example/cb", identity: () => ({})})
31
+
32
+ test("a failed sign-in start doesn't block the next one", async () => {
33
+ const oauth = client()
34
+ fetchMock.mockImplementationOnce(async () => new Response("down", {status: 503}))
35
+ await expect(oauth.authorize()).rejects.toThrow()
36
+ void oauth.authorize()
37
+ await vi.waitFor(() => expect(assign).toHaveBeenCalledTimes(1))
38
+ expect(new URL(assign.mock.calls[0][0]).searchParams.get("code_challenge_method")).toBe("S256")
39
+ })
40
+
41
+ test("a sign-in started during a logout redirect waits for it", async () => {
42
+ const oauth = client()
43
+ metadata.end_session_endpoint = `${SERVER}/oauth/logout`
44
+ void oauth.logout("rt")
45
+ void oauth.authorize()
46
+ await vi.waitFor(() => expect(assign).toHaveBeenCalledTimes(1))
47
+ expect(assign.mock.calls[0][0]).toContain("/oauth/logout")
48
+ })
49
+
50
+ test("a sign-in waiting behind a failed logout goes ahead", async () => {
51
+ const oauth = client()
52
+ fetchMock.mockImplementationOnce(async () => new Response("down", {status: 503}))
53
+ const logout = oauth.logout(undefined)
54
+ void oauth.authorize()
55
+ await expect(logout).rejects.toThrow()
56
+ await vi.waitFor(() => expect(assign).toHaveBeenCalledTimes(1))
57
+ expect(assign.mock.calls[0][0]).toContain("/oauth/authorize")
58
+ })
59
+
60
+ test("coming back from the back/forward cache releases the redirect", async () => {
61
+ const oauth = client()
62
+ void oauth.authorize()
63
+ await vi.waitFor(() => expect(assign).toHaveBeenCalledTimes(1))
64
+ const [event, onPageShow] = (window.addEventListener as ReturnType<typeof vi.fn>).mock.calls[0]
65
+ expect(event).toBe("pageshow")
66
+ onPageShow()
67
+ void oauth.authorize()
68
+ await vi.waitFor(() => expect(assign).toHaveBeenCalledTimes(2))
69
+ })
70
+
71
+ test("without an end-session endpoint, logout resolves once the token is revoked", async () => {
72
+ await client().logout("rt")
73
+ expect(fetchMock).toHaveBeenCalledWith(METADATA.revocation_endpoint, expect.objectContaining({method: "POST"}))
74
+ expect(assign).not.toHaveBeenCalled()
75
+ })
76
+ })
@@ -0,0 +1,187 @@
1
+ /// <reference lib="dom" />
2
+ import {decodeJwt} from "jose"
3
+ import {NOT_AUTHORIZED} from "@grest-ts/schema"
4
+
5
+ // Structurally GGAuthSession's GGAuthResult (exported only from @grest-ts/auth's browser entry).
6
+ export interface GGOAuthResult<I> {
7
+ tokens: {access: {token: string, expiresAt: number}, refresh?: {token: string, expiresAt: number}}
8
+ data: I
9
+ }
10
+
11
+ export interface GGOAuthClientConfig<I> {
12
+ // Where the authorization server's metadata is (`{server}/.well-known/oauth-authorization-server`).
13
+ // Responses are checked against the `issuer` it declares.
14
+ server: string
15
+ clientId: string
16
+ redirectUri: string
17
+ postLogoutRedirectUri?: string
18
+ // RFC 8707 resource the access token is for; defaults to the issuer (an authorization server
19
+ // protecting its own API). Always sent: a request with `openid` and no resource would get a
20
+ // userinfo-only token instead of an API token.
21
+ resource?: string
22
+ scope?: string
23
+ // The session's identity `data`, derived from the issued access token.
24
+ identity: (accessToken: string) => I
25
+ // Assumed refresh lifetime (the token response doesn't carry it). A server-side expiry earlier
26
+ // than this surfaces as NOT_AUTHORIZED on refresh, which ends the session.
27
+ refreshTtlMs?: number
28
+ }
29
+
30
+ type Pending = {state: string, verifier: string, returnTo?: string}
31
+ type Metadata = {issuer: string, authorization_endpoint: string, token_endpoint: string, revocation_endpoint?: string, end_session_endpoint?: string}
32
+
33
+ const PENDING_KEY = "gg-oauth-pending"
34
+
35
+ // Browser OAuth client (authorization code + PKCE S256), the `refresh` of a GGAuthSession — see README.
36
+ // The token/revocation endpoints are OAuth protocol endpoints (form-encoded), not grest-ts contracts.
37
+ export class GGOAuthClient<I = unknown> {
38
+
39
+ private metadata: Promise<Metadata> | undefined
40
+ // The redirect away being prepared: a second one (e.g. a signed-out view starting sign-in while
41
+ // logout redirects) waits for it — forever if it navigates, and takes over if it fails.
42
+ private leaving: Promise<void> | undefined
43
+
44
+ private readonly config: GGOAuthClientConfig<I>
45
+
46
+ constructor(config: GGOAuthClientConfig<I>) {
47
+ this.config = config
48
+ }
49
+
50
+ // Redirects the browser to the authorization server; resolves never (the page unloads).
51
+ public authorize = (opts?: {returnTo?: string}): Promise<void> => this.leave(async () => {
52
+ const meta = await this.discover()
53
+ const verifier = randomString()
54
+ const pending: Pending = {state: randomString(), verifier, returnTo: opts?.returnTo}
55
+ sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending))
56
+ const url = new URL(meta.authorization_endpoint)
57
+ url.search = new URLSearchParams({
58
+ response_type: "code",
59
+ client_id: this.config.clientId,
60
+ redirect_uri: this.config.redirectUri,
61
+ state: pending.state,
62
+ code_challenge: await s256(verifier),
63
+ code_challenge_method: "S256",
64
+ resource: this.config.resource ?? meta.issuer,
65
+ ...(this.config.scope ? {scope: this.config.scope} : {}),
66
+ }).toString()
67
+ return url.href
68
+ })
69
+
70
+ public isCallback = (href: string): boolean => {
71
+ const url = new URL(href)
72
+ return url.origin + url.pathname === new URL(this.config.redirectUri).href.replace(/[?#].*$/, "")
73
+ && (url.searchParams.has("code") || url.searchParams.has("error"))
74
+ }
75
+
76
+ public handleCallback = async (href: string): Promise<{result: GGOAuthResult<I>, returnTo?: string}> => {
77
+ const params = new URL(href).searchParams
78
+ const raw = sessionStorage.getItem(PENDING_KEY)
79
+ sessionStorage.removeItem(PENDING_KEY)
80
+ const pending = raw ? JSON.parse(raw) as Pending : undefined
81
+ if (!pending || params.get("state") !== pending.state) throw new NOT_AUTHORIZED({debugMessage: "OAuth state mismatch"})
82
+ // RFC 9207: reject a response from a different authorization server (mix-up attack).
83
+ const iss = params.get("iss")
84
+ if (iss !== null && iss !== (await this.discover()).issuer) throw new NOT_AUTHORIZED({debugMessage: "OAuth issuer mismatch"})
85
+ const error = params.get("error")
86
+ if (error) throw new NOT_AUTHORIZED({debugMessage: `OAuth ${error}: ${params.get("error_description") ?? ""}`})
87
+ const result = await this.token({
88
+ grant_type: "authorization_code",
89
+ code: params.get("code") ?? "",
90
+ redirect_uri: this.config.redirectUri,
91
+ code_verifier: pending.verifier,
92
+ })
93
+ return {result, returnTo: pending.returnTo}
94
+ }
95
+
96
+ public refresh = async ({refreshToken}: {refreshToken: string}): Promise<GGOAuthResult<I>> =>
97
+ this.token({grant_type: "refresh_token", refresh_token: refreshToken})
98
+
99
+ // Revokes the refresh token and ends the authorization server's login session (redirects away
100
+ // when the server supports it).
101
+ public logout = (refreshToken: string | undefined): Promise<void> => this.leave(async () => {
102
+ const meta = await this.discover()
103
+ const revoke = refreshToken && meta.revocation_endpoint
104
+ ? fetch(meta.revocation_endpoint, {method: "POST", keepalive: true, signal: AbortSignal.timeout(5000), body: new URLSearchParams({token: refreshToken, client_id: this.config.clientId})}).then(() => undefined, () => undefined)
105
+ : Promise.resolve()
106
+ if (!meta.end_session_endpoint) {
107
+ await revoke
108
+ return undefined
109
+ }
110
+ // Revocation not awaited (keepalive outlives the navigation): the sooner the redirect, the
111
+ // shorter the window in which other tabs could still sign in silently.
112
+ const url = new URL(meta.end_session_endpoint)
113
+ url.search = new URLSearchParams({
114
+ client_id: this.config.clientId,
115
+ ...(this.config.postLogoutRedirectUri ? {post_logout_redirect_uri: this.config.postLogoutRedirectUri} : {}),
116
+ }).toString()
117
+ return url.href
118
+ })
119
+
120
+ private async leave(target: () => Promise<string | undefined>): Promise<void> {
121
+ while (this.leaving) await this.leaving.catch(() => undefined)
122
+ const run = (async () => {
123
+ const url = await target()
124
+ if (url === undefined) return
125
+ // Back from the back/forward cache (navigation undone): the redirect is over.
126
+ window.addEventListener("pageshow", () => { if (this.leaving === run) this.leaving = undefined }, {once: true})
127
+ window.location.assign(url)
128
+ await new Promise(() => undefined)
129
+ })()
130
+ this.leaving = run
131
+ try {
132
+ await run
133
+ } finally {
134
+ if (this.leaving === run) this.leaving = undefined
135
+ }
136
+ }
137
+
138
+ private async token(body: Record<string, string>): Promise<GGOAuthResult<I>> {
139
+ const meta = await this.discover()
140
+ const res = await fetch(meta.token_endpoint, {
141
+ method: "POST",
142
+ body: new URLSearchParams({...body, client_id: this.config.clientId, resource: this.config.resource ?? meta.issuer}),
143
+ })
144
+ const json = await res.json().catch(() => ({})) as Record<string, unknown>
145
+ if (!res.ok) {
146
+ // invalid_grant = the refresh/code is dead → fatal for the session; anything else may be transient.
147
+ if (res.status === 400 || res.status === 401) throw new NOT_AUTHORIZED({debugMessage: `OAuth ${String(json.error)}`})
148
+ throw new Error(`OAuth token endpoint ${res.status}`)
149
+ }
150
+ const now = Date.now()
151
+ const access = String(json.access_token)
152
+ return {
153
+ tokens: {
154
+ access: {token: access, expiresAt: now + Number(json.expires_in) * 1000},
155
+ ...(json.refresh_token ? {refresh: {token: String(json.refresh_token), expiresAt: now + (this.config.refreshTtlMs ?? 30 * 24 * 3600 * 1000)}} : {}),
156
+ },
157
+ data: this.config.identity(access),
158
+ }
159
+ }
160
+
161
+ private discover(): Promise<Metadata> {
162
+ this.metadata ??= fetch(`${this.config.server}/.well-known/oauth-authorization-server`).then(async res => {
163
+ if (!res.ok) throw new Error(`OAuth discovery failed: ${res.status}`)
164
+ return await res.json() as Metadata
165
+ })
166
+ this.metadata.catch(() => { this.metadata = undefined })
167
+ return this.metadata
168
+ }
169
+ }
170
+
171
+ // A grest-ts access token's payload, unverified — for UI identity only; the server re-verifies
172
+ // every request.
173
+ export function decodeAccessToken<T>(accessToken: string): {sub: string, exp: number, data: T} {
174
+ return decodeJwt(accessToken) as unknown as {sub: string, exp: number, data: T}
175
+ }
176
+
177
+ function randomString(): string {
178
+ return base64url(crypto.getRandomValues(new Uint8Array(32)))
179
+ }
180
+
181
+ async function s256(verifier: string): Promise<string> {
182
+ return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))))
183
+ }
184
+
185
+ function base64url(bytes: Uint8Array): string {
186
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
187
+ }
@@ -0,0 +1,5 @@
1
+ import "./_dedupCheck";
2
+ export {GGOAuthClient, decodeAccessToken} from "./browser/GGOAuthClient"
3
+ export type {GGOAuthClientConfig, GGOAuthResult} from "./browser/GGOAuthClient"
4
+ export {GGOAuthInteractionContract, IsGGOAuthInteraction, IsGGOAuthInteractionClient} from "./shared/interactionContract"
5
+ export type {GGOAuthInteraction, GGOAuthInteractionClient} from "./shared/interactionContract"
@@ -0,0 +1,7 @@
1
+ import "./_dedupCheck";
2
+ export {GGOAuthServer} from "./node/GGOAuthServer"
3
+ export type {GGOAuthServerOptions, GGOAuthFirstPartyClient, GGOAuthResource} from "./node/GGOAuthServer"
4
+ export type {GGOAuthStore, GGOAuthPayload} from "./node/store/GGOAuthStore"
5
+ export {InMemoryOAuthStore} from "./node/store/InMemoryOAuthStore"
6
+ export {GGOAuthInteractionContract, IsGGOAuthInteraction, IsGGOAuthInteractionClient} from "./shared/interactionContract"
7
+ export type {GGOAuthInteraction, GGOAuthInteractionClient} from "./shared/interactionContract"