@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.
- package/LICENSE +21 -0
- package/README.md +165 -2
- package/dist/src/_dedupCheck.d.ts +2 -0
- package/dist/src/_dedupCheck.d.ts.map +1 -0
- package/dist/src/_dedupCheck.js +28 -0
- package/dist/src/_dedupCheck.js.map +1 -0
- package/dist/src/browser/GGOAuthClient.d.ts +50 -0
- package/dist/src/browser/GGOAuthClient.d.ts.map +1 -0
- package/dist/src/browser/GGOAuthClient.js +152 -0
- package/dist/src/browser/GGOAuthClient.js.map +1 -0
- package/dist/src/index-browser.d.ts +6 -0
- package/dist/src/index-browser.d.ts.map +1 -0
- package/dist/src/index-browser.js +4 -0
- package/dist/src/index-browser.js.map +1 -0
- package/dist/src/index-node.d.ts +8 -0
- package/dist/src/index-node.d.ts.map +1 -0
- package/dist/src/index-node.js +5 -0
- package/dist/src/index-node.js.map +1 -0
- package/dist/src/node/GGOAuthServer.d.ts +72 -0
- package/dist/src/node/GGOAuthServer.d.ts.map +1 -0
- package/dist/src/node/GGOAuthServer.js +430 -0
- package/dist/src/node/GGOAuthServer.js.map +1 -0
- package/dist/src/node/store/DynamoOAuthStore.d.ts +28 -0
- package/dist/src/node/store/DynamoOAuthStore.d.ts.map +1 -0
- package/dist/src/node/store/DynamoOAuthStore.js +78 -0
- package/dist/src/node/store/DynamoOAuthStore.js.map +1 -0
- package/dist/src/node/store/GGOAuthStore.d.ts +18 -0
- package/dist/src/node/store/GGOAuthStore.d.ts.map +1 -0
- package/dist/src/node/store/GGOAuthStore.js +16 -0
- package/dist/src/node/store/GGOAuthStore.js.map +1 -0
- package/dist/src/node/store/InMemoryOAuthStore.d.ts +16 -0
- package/dist/src/node/store/InMemoryOAuthStore.d.ts.map +1 -0
- package/dist/src/node/store/InMemoryOAuthStore.js +44 -0
- package/dist/src/node/store/InMemoryOAuthStore.js.map +1 -0
- package/dist/src/shared/interactionContract.d.ts +77 -0
- package/dist/src/shared/interactionContract.d.ts.map +1 -0
- package/dist/src/shared/interactionContract.js +34 -0
- package/dist/src/shared/interactionContract.js.map +1 -0
- package/dist/src/tsconfig.json +18 -0
- package/dist/tsconfig.publish.tsbuildinfo +1 -0
- package/package.json +71 -4
- package/src/_dedupCheck.ts +29 -0
- package/src/browser/GGOAuthClient.test.ts +76 -0
- package/src/browser/GGOAuthClient.ts +187 -0
- package/src/index-browser.ts +5 -0
- package/src/index-node.ts +7 -0
- package/src/node/GGOAuthServer.test.ts +416 -0
- package/src/node/GGOAuthServer.ts +500 -0
- package/src/node/store/DynamoOAuthStore.ts +93 -0
- package/src/node/store/GGOAuthStore.ts +41 -0
- package/src/node/store/InMemoryOAuthStore.ts +54 -0
- package/src/shared/interactionContract.ts +39 -0
- 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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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"
|