@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
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import {createECDH, createHash, hkdfSync, randomBytes} from "node:crypto"
|
|
2
|
+
import type {IncomingMessage, ServerResponse} from "node:http"
|
|
3
|
+
import Provider, {errors as oidcErrors, type Configuration, type InteractionResults, type KoaContextWithOIDC} from "oidc-provider"
|
|
4
|
+
import {NOT_FOUND} from "@grest-ts/schema"
|
|
5
|
+
import type {GGHttpServer} from "@grest-ts/http"
|
|
6
|
+
import type {GGAccessPayload, GGAuthAccessToken} from "@grest-ts/auth"
|
|
7
|
+
import {GGOAuthInteractionContract, type GGOAuthInteraction} from "../shared/interactionContract"
|
|
8
|
+
import {toOidcAdapter, type GGOAuthStore} from "./store/GGOAuthStore"
|
|
9
|
+
|
|
10
|
+
export interface GGOAuthFirstPartyClient {
|
|
11
|
+
clientId: string
|
|
12
|
+
name: string
|
|
13
|
+
redirectUris: string[]
|
|
14
|
+
postLogoutRedirectUris?: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// One API the server issues tokens for (RFC 8707 resource indicator → its own audience and claims).
|
|
18
|
+
export interface GGOAuthResource<C extends object> {
|
|
19
|
+
// Absolute URL identifying the API; for an MCP server, its URL exactly as users enter it.
|
|
20
|
+
resource: () => string
|
|
21
|
+
// Tokens for this resource ARE this token kind (signer, audience — defaults to the resource —,
|
|
22
|
+
// TTL, claim schema), so the wire already verifying it accepts them. Signer must expose jwsKey().
|
|
23
|
+
accessToken: GGAuthAccessToken<C>
|
|
24
|
+
// At least one: a request granting no scope is refused. What they permit is up to the app.
|
|
25
|
+
scopes: [string, ...string[]]
|
|
26
|
+
// Re-run at every issuance (code exchange and each refresh), like GGAuthRefreshToken's resolve:
|
|
27
|
+
// permission changes apply on refresh. undefined = subject gone → invalid_grant.
|
|
28
|
+
claims: (subject: string, client: {clientId: string, name: string}) => Promise<C | undefined>
|
|
29
|
+
// Only pre-registered first-party clients may get tokens for it.
|
|
30
|
+
firstPartyOnly?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface GGOAuthServerOptions {
|
|
34
|
+
// Public origin of the authorization server, no path (e.g. https://auth.example.com).
|
|
35
|
+
issuer: () => string
|
|
36
|
+
// Other public origins it is also reached on. A sign-in that started on one of these finishes
|
|
37
|
+
// there (its cookie lives there); any other request origin is ignored in favour of the issuer.
|
|
38
|
+
origins?: () => string[]
|
|
39
|
+
store: GGOAuthStore
|
|
40
|
+
// Derives the cookie-signing key and the ES256 key of the published JWKS (id tokens). Rotating it
|
|
41
|
+
// signs everyone out of the authorization server; issued access/refresh tokens are unaffected.
|
|
42
|
+
secret: () => string
|
|
43
|
+
// The first is the default for first-party clients; others default to the first one they may use.
|
|
44
|
+
resources: [GGOAuthResource<any>, ...GGOAuthResource<any>[]]
|
|
45
|
+
// The app's login/consent page; it receives the interaction uid and uses GGOAuthInteractionContract.
|
|
46
|
+
// `request` lets a first-party client's sign-in stay on the origin it was opened from.
|
|
47
|
+
interactionUrl: (uid: string, request: {redirectUri: string, firstParty: boolean}) => string
|
|
48
|
+
// Pre-registered app clients: no consent screen, silent re-auth while the login session lives.
|
|
49
|
+
// Read once, on first use (config isn't readable at compose time).
|
|
50
|
+
firstPartyClients?: () => GGOAuthFirstPartyClient[]
|
|
51
|
+
// Let any client self-register (RFC 7591).
|
|
52
|
+
dynamicRegistration?: boolean
|
|
53
|
+
// Accept clients identified by a metadata document URL (CIMD, what MCP clients prefer), fetched
|
|
54
|
+
// only from https URLs on the default port under one of these prefixes (end each with "/"),
|
|
55
|
+
// e.g. a vendor's hosted client documents: "https://claude.ai/oauth/".
|
|
56
|
+
clientIdMetadataDocument?: {allowedUrlPrefixes: () => string[]}
|
|
57
|
+
// Absolute refresh lifetime: rotation never extends it (OAuth for browser apps, RFC 10017).
|
|
58
|
+
refreshTtlSec?: number
|
|
59
|
+
sessionTtlSec?: number
|
|
60
|
+
// Trust X-Forwarded-Proto (behind a TLS-terminating load balancer). X-Forwarded-Host is never
|
|
61
|
+
// trusted: clients can send it through a load balancer that doesn't set it.
|
|
62
|
+
proxy?: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const BASE = "/oauth"
|
|
66
|
+
const OIDC_SCOPES = ["openid", "offline_access"]
|
|
67
|
+
const TICKET = "GGOAuthTicket"
|
|
68
|
+
// Claims resolved during a refresh before the presented token was consumed (see rotateRefreshToken).
|
|
69
|
+
const prefetchedClaims = new WeakMap<object, {resource: string, claims: object}>()
|
|
70
|
+
|
|
71
|
+
type Ticket = {uid: string, result: InteractionResults}
|
|
72
|
+
|
|
73
|
+
// OAuth 2.1 authorization server on a GGHttpServer. Protocol (PKCE, DCR, CIMD, resource indicators,
|
|
74
|
+
// RFC 9207 iss, refresh rotation + reuse revocation, revocation, discovery) is panva oidc-provider;
|
|
75
|
+
// this class owns the grest-ts seams: mounting, storage, token claims and the login/consent handoff.
|
|
76
|
+
export class GGOAuthServer {
|
|
77
|
+
|
|
78
|
+
private _provider: Provider | undefined
|
|
79
|
+
private _callback: ReturnType<Provider["callback"]> | undefined
|
|
80
|
+
private _firstParty: Map<string, GGOAuthFirstPartyClient> | undefined
|
|
81
|
+
private readonly options: GGOAuthServerOptions
|
|
82
|
+
|
|
83
|
+
constructor(options: GGOAuthServerOptions) {
|
|
84
|
+
// Resources sharing a signer are told apart only by audience: a token for one must never
|
|
85
|
+
// verify as another's (e.g. an app's MCP token as the first-party API token).
|
|
86
|
+
const audiences = options.resources.map(r => r.accessToken.audience)
|
|
87
|
+
if (audiences.some(a => !a) || new Set(audiences).size !== audiences.length) {
|
|
88
|
+
throw new Error("GGOAuthServer: every resource's accessToken needs its own audience")
|
|
89
|
+
}
|
|
90
|
+
this.options = options
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private get firstParty(): Map<string, GGOAuthFirstPartyClient> {
|
|
94
|
+
this._firstParty ??= new Map((this.options.firstPartyClients?.() ?? []).map(c => [c.clientId, c]))
|
|
95
|
+
return this._firstParty
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public mount(server: GGHttpServer): void {
|
|
99
|
+
const handle = async (req: IncomingMessage, res: ServerResponse) => {
|
|
100
|
+
delete req.headers["x-forwarded-host"]
|
|
101
|
+
const path = new URL(req.url ?? "/", "http://x").pathname
|
|
102
|
+
const interaction = path.match(/^\/oauth\/interaction\/([^/]+)(\/complete)?$/)
|
|
103
|
+
if (interaction && req.method === "GET") {
|
|
104
|
+
if (interaction[2]) return this.finishInteraction(req, res, interaction[1])
|
|
105
|
+
const found = await this.provider().Interaction.find(interaction[1])
|
|
106
|
+
if (!found) {
|
|
107
|
+
res.writeHead(400, {"content-type": "text/plain"})
|
|
108
|
+
res.end("Sign-in request expired")
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
const firstParty = this.firstParty.has(String(found.params.client_id))
|
|
112
|
+
res.writeHead(303, {location: this.options.interactionUrl(interaction[1], {redirectUri: String(found.params.redirect_uri), firstParty})})
|
|
113
|
+
res.end()
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
this._callback ??= this.provider().callback()
|
|
117
|
+
await this._callback(req, res)
|
|
118
|
+
}
|
|
119
|
+
for (const method of ["GET", "POST"] as const) server.registerRoute(method, `${BASE}/*`, handle, "GGOAuthServer")
|
|
120
|
+
server.registerRoute("GET", "/.well-known/openid-configuration", handle, "GGOAuthServer")
|
|
121
|
+
server.registerRoute("GET", "/.well-known/oauth-authorization-server", handle, "GGOAuthServer")
|
|
122
|
+
|
|
123
|
+
server.announce(`${BASE}/`, "GGOAuthServer")
|
|
124
|
+
server.announce("/.well-known/openid-configuration", "GGOAuthServer")
|
|
125
|
+
server.announce("/.well-known/oauth-authorization-server", "GGOAuthServer")
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Serves `path` as an OAuth-protected resource: requests without a valid token for `resource` get
|
|
129
|
+
// 401 + `WWW-Authenticate: Bearer resource_metadata=…`, and its RFC 9728 metadata is published —
|
|
130
|
+
// the discovery handshake MCP clients run before signing in.
|
|
131
|
+
public protect<C extends object>(
|
|
132
|
+
server: GGHttpServer,
|
|
133
|
+
resource: GGOAuthResource<C>,
|
|
134
|
+
path: string,
|
|
135
|
+
handle: (req: IncomingMessage, res: ServerResponse, token: GGAccessPayload<C>) => Promise<void>,
|
|
136
|
+
): void {
|
|
137
|
+
const metadataPath = `/.well-known/oauth-protected-resource${path}`
|
|
138
|
+
server.registerRoute("GET", metadataPath, async (_req, res) => {
|
|
139
|
+
res.writeHead(200, {"content-type": "application/json"})
|
|
140
|
+
res.end(JSON.stringify({
|
|
141
|
+
resource: resource.resource(),
|
|
142
|
+
authorization_servers: [this.options.issuer()],
|
|
143
|
+
scopes_supported: resource.scopes,
|
|
144
|
+
bearer_methods_supported: ["header"],
|
|
145
|
+
}))
|
|
146
|
+
}, "GGOAuthServer")
|
|
147
|
+
const guarded = async (req: IncomingMessage, res: ServerResponse) => {
|
|
148
|
+
const auth = req.headers.authorization
|
|
149
|
+
const token = auth?.toLowerCase().startsWith("bearer ")
|
|
150
|
+
? await resource.accessToken.verify(auth.slice(7)).catch(() => undefined)
|
|
151
|
+
: undefined
|
|
152
|
+
if (!token) {
|
|
153
|
+
const metadataUrl = `${new URL(resource.resource()).origin}${metadataPath}`
|
|
154
|
+
res.writeHead(401, {
|
|
155
|
+
"www-authenticate": `Bearer resource_metadata="${metadataUrl}", scope="${resource.scopes.join(" ")}"${auth ? `, error="invalid_token"` : ""}`,
|
|
156
|
+
})
|
|
157
|
+
res.end()
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
await handle(req, res, token)
|
|
161
|
+
}
|
|
162
|
+
for (const method of ["GET", "POST", "DELETE"] as const) server.registerRoute(method, path, guarded, "GGOAuthServer")
|
|
163
|
+
server.announce(path, "GGOAuthServer")
|
|
164
|
+
server.announce(metadataPath, "GGOAuthServer")
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Implementation of GGOAuthInteractionContract; `subject` reads the signed-in user from the app's wire.
|
|
168
|
+
public interactionApi(subject: () => string): typeof GGOAuthInteractionContract.infer {
|
|
169
|
+
return {
|
|
170
|
+
get: async ({uid}) => this.describeInteraction(uid),
|
|
171
|
+
approve: async ({uid}) => ({redirectUrl: await this.approve(uid, subject())}),
|
|
172
|
+
deny: async ({uid}) => ({redirectUrl: await this.ticket(await this.findInteraction(uid), {error: "access_denied", error_description: "The user denied the request"})}),
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
public listGrants = async (subject: string): Promise<Array<{grantId: string, clientId: string, clientName: string, createdAt: number}>> => {
|
|
177
|
+
const p = this.provider()
|
|
178
|
+
const grants = await Promise.all((await this.options.store.listIdsByAccount("Grant", subject)).map(id => p.Grant.find(id)))
|
|
179
|
+
return await Promise.all(grants
|
|
180
|
+
.filter(g => g?.clientId && g.accountId === subject)
|
|
181
|
+
.map(async g => ({
|
|
182
|
+
grantId: g!.jti,
|
|
183
|
+
clientId: g!.clientId!,
|
|
184
|
+
// A client metadata document may be unreachable; the grant must still be listed (and revocable).
|
|
185
|
+
clientName: String((await p.Client.find(g!.clientId!).catch(() => undefined))?.clientName ?? g!.clientId),
|
|
186
|
+
createdAt: (g!.iat ?? 0) * 1000,
|
|
187
|
+
})))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Kills the grant's refresh tokens and codes now; already-issued access tokens live out their TTL.
|
|
191
|
+
public revokeGrant = async (subject: string, grantId: string): Promise<void> => {
|
|
192
|
+
const grant = await this.provider().Grant.find(grantId)
|
|
193
|
+
if (!grant || grant.accountId !== subject) throw new NOT_FOUND()
|
|
194
|
+
await this.options.store.revokeByGrantId(grantId)
|
|
195
|
+
// The grant row carries its id as jti, not grantId, so revokeByGrantId leaves it.
|
|
196
|
+
await this.options.store.destroy("Grant", grantId)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// "Log out everywhere": every grant and login session of the subject.
|
|
200
|
+
public revokeAll = async (subject: string): Promise<void> => {
|
|
201
|
+
const store = this.options.store
|
|
202
|
+
const [grantIds, sessionIds] = await Promise.all([store.listIdsByAccount("Grant", subject), store.listIdsByAccount("Session", subject)])
|
|
203
|
+
await Promise.all([
|
|
204
|
+
...grantIds.map(id => Promise.all([store.revokeByGrantId(id), store.destroy("Grant", id)])),
|
|
205
|
+
...sessionIds.map(id => store.destroy("Session", id)),
|
|
206
|
+
])
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async describeInteraction(uid: string): Promise<GGOAuthInteraction> {
|
|
210
|
+
const interaction = await this.findInteraction(uid)
|
|
211
|
+
const clientId = String(interaction.params.client_id)
|
|
212
|
+
const client = await this.provider().Client.find(clientId)
|
|
213
|
+
if (!client) throw new NOT_FOUND({debugMessage: "Unknown OAuth client"})
|
|
214
|
+
const hosts = [...new Set((client.redirectUris ?? []).map(u => new URL(u).host))]
|
|
215
|
+
return {
|
|
216
|
+
uid,
|
|
217
|
+
client: {
|
|
218
|
+
clientId,
|
|
219
|
+
name: this.firstParty.get(clientId)?.name ?? String(client.clientName ?? clientId),
|
|
220
|
+
firstParty: this.firstParty.has(clientId),
|
|
221
|
+
redirectHosts: hosts,
|
|
222
|
+
loopbackOnly: hosts.length > 0 && hosts.every(h => /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(h)),
|
|
223
|
+
},
|
|
224
|
+
scopes: requestedScopes(interaction.params),
|
|
225
|
+
resource: this.requestedResource(interaction.params, clientId).resource(),
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private async approve(uid: string, subject: string): Promise<string> {
|
|
230
|
+
const p = this.provider()
|
|
231
|
+
const interaction = await this.findInteraction(uid)
|
|
232
|
+
const clientId = String(interaction.params.client_id)
|
|
233
|
+
const existing = interaction.grantId ? await p.Grant.find(interaction.grantId) : undefined
|
|
234
|
+
const grant = existing?.accountId === subject ? existing : new p.Grant({accountId: subject, clientId})
|
|
235
|
+
this.grantRequested(grant, interaction.params)
|
|
236
|
+
const grantId = await grant.save()
|
|
237
|
+
return this.ticket(interaction, {login: {accountId: subject}, consent: {grantId}})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async findInteraction(uid: string): Promise<InstanceType<Provider["Interaction"]>> {
|
|
241
|
+
const interaction = await this.provider().Interaction.find(uid)
|
|
242
|
+
if (!interaction) throw new NOT_FOUND({debugMessage: "Unknown or expired OAuth interaction"})
|
|
243
|
+
return interaction
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// The result is applied only when the browser that started the flow (it holds the interaction
|
|
247
|
+
// cookie) follows the redirect: a user approving someone else's flow can't hand them a code.
|
|
248
|
+
private async ticket(interaction: InstanceType<Provider["Interaction"]>, result: InteractionResults): Promise<string> {
|
|
249
|
+
const uid = interaction.uid
|
|
250
|
+
const id = randomBytes(24).toString("base64url")
|
|
251
|
+
await this.options.store.upsert(TICKET, id, {uid, result} satisfies Ticket, 120)
|
|
252
|
+
// Back to the origin the browser started on (its interaction cookie lives there) — but only a
|
|
253
|
+
// known one: returnTo comes from the request's Host, which a client can set to its own server.
|
|
254
|
+
const started = new URL(interaction.returnTo).origin
|
|
255
|
+
const known = [this.options.issuer(), ...(this.options.origins?.() ?? [])].map(o => new URL(o).origin)
|
|
256
|
+
return `${known.includes(started) ? started : known[0]}${BASE}/interaction/${uid}/complete?ticket=${id}`
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private async finishInteraction(req: IncomingMessage, res: ServerResponse, uid: string): Promise<void> {
|
|
260
|
+
const id = new URL(req.url ?? "/", "http://x").searchParams.get("ticket") ?? ""
|
|
261
|
+
const ticket = id ? await this.options.store.find(TICKET, id) as Ticket | undefined : undefined
|
|
262
|
+
if (id) await this.options.store.destroy(TICKET, id)
|
|
263
|
+
if (!ticket || ticket.uid !== uid) {
|
|
264
|
+
res.writeHead(400, {"content-type": "text/plain"})
|
|
265
|
+
res.end("Invalid or expired sign-in link")
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
await this.provider().interactionFinished(req, res, ticket.result, {mergeWithLastSubmission: false})
|
|
270
|
+
} catch (err) {
|
|
271
|
+
if (!(err instanceof oidcErrors.SessionNotFound)) throw err
|
|
272
|
+
res.writeHead(400, {"content-type": "text/plain"})
|
|
273
|
+
res.end("This sign-in was started in another browser")
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private grantRequested(grant: InstanceType<Provider["Grant"]>, params: Record<string, unknown>): void {
|
|
278
|
+
const requested = requestedScopes(params)
|
|
279
|
+
const oidc = requested.filter(s => OIDC_SCOPES.includes(s))
|
|
280
|
+
if (oidc.length) grant.addOIDCScope(oidc.join(" "))
|
|
281
|
+
const resource = this.requestedResource(params, String(grant.clientId))
|
|
282
|
+
const resourceScopes = requested.filter(s => resource.scopes.includes(s))
|
|
283
|
+
if (resourceScopes.length) grant.addResourceScope(resource.resource(), resourceScopes.join(" "))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private requestedResource(params: Record<string, unknown>, clientId: string): GGOAuthResource<any> {
|
|
287
|
+
const indicator = typeof params.resource === "string" ? params.resource : undefined
|
|
288
|
+
return (indicator && this.resource(indicator)) || this.defaultResource(clientId)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private resource(indicator: string | undefined): GGOAuthResource<any> | undefined {
|
|
292
|
+
return this.options.resources.find(r => r.resource() === indicator)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private clientOf(ctx: KoaContextWithOIDC): {clientId: string, name: string} {
|
|
296
|
+
const client = ctx.oidc.client!
|
|
297
|
+
return {clientId: client.clientId, name: this.firstParty.get(client.clientId)?.name ?? String(client.clientName ?? client.clientId)}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private usableResource(indicator: string, clientId: string): GGOAuthResource<any> {
|
|
301
|
+
const resource = this.resource(indicator)
|
|
302
|
+
if (!resource) throw new oidcErrors.InvalidTarget()
|
|
303
|
+
if (resource.firstPartyOnly && !this.firstParty.has(clientId)) throw new oidcErrors.InvalidTarget("resource not available to this client")
|
|
304
|
+
return resource
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private defaultResource(clientId: string): GGOAuthResource<any> {
|
|
308
|
+
const firstParty = this.firstParty.has(clientId)
|
|
309
|
+
return this.options.resources.find(r => firstParty || !r.firstPartyOnly) ?? this.options.resources[0]
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private provider(): Provider {
|
|
313
|
+
if (this._provider) return this._provider
|
|
314
|
+
const o = this.options
|
|
315
|
+
const refreshTtlSec = o.refreshTtlSec ?? 30 * 24 * 3600
|
|
316
|
+
const firstParty = this.firstParty
|
|
317
|
+
|
|
318
|
+
const config: Configuration = {
|
|
319
|
+
adapter: toOidcAdapter(o.store),
|
|
320
|
+
jwks: {keys: [deriveEs256Jwk(o.secret())]},
|
|
321
|
+
cookies: {keys: [hkdf(o.secret(), "@grest-ts/oauth cookies").toString("base64url")]},
|
|
322
|
+
clients: [...firstParty.values()].map(c => ({
|
|
323
|
+
client_id: c.clientId,
|
|
324
|
+
client_name: c.name,
|
|
325
|
+
redirect_uris: c.redirectUris,
|
|
326
|
+
post_logout_redirect_uris: c.postLogoutRedirectUris ?? [],
|
|
327
|
+
token_endpoint_auth_method: "none",
|
|
328
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
329
|
+
response_types: ["code"],
|
|
330
|
+
application_type: "web",
|
|
331
|
+
})),
|
|
332
|
+
clientDefaults: {
|
|
333
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
334
|
+
response_types: ["code"],
|
|
335
|
+
token_endpoint_auth_method: "none",
|
|
336
|
+
id_token_signed_response_alg: "ES256",
|
|
337
|
+
// Undeclared = native: CLI clients (e.g. Claude Code's metadata document) redirect to a
|
|
338
|
+
// loopback address on a per-run port, which oidc-provider only port-matches for native.
|
|
339
|
+
application_type: "native",
|
|
340
|
+
},
|
|
341
|
+
routes: {
|
|
342
|
+
authorization: `${BASE}/authorize`, token: `${BASE}/token`, registration: `${BASE}/register`,
|
|
343
|
+
jwks: `${BASE}/jwks`, revocation: `${BASE}/revoke`, end_session: `${BASE}/logout`, userinfo: `${BASE}/userinfo`,
|
|
344
|
+
introspection: `${BASE}/introspect`, pushed_authorization_request: `${BASE}/par`, code_verification: `${BASE}/device`,
|
|
345
|
+
device_authorization: `${BASE}/device/auth`, backchannel_authentication: `${BASE}/backchannel`, challenge: `${BASE}/challenge`,
|
|
346
|
+
},
|
|
347
|
+
interactions: {url: (_ctx, interaction) => `${BASE}/interaction/${interaction.uid}`},
|
|
348
|
+
scopes: OIDC_SCOPES,
|
|
349
|
+
responseTypes: ["code"],
|
|
350
|
+
pkce: {required: () => true},
|
|
351
|
+
features: {
|
|
352
|
+
devInteractions: {enabled: false},
|
|
353
|
+
revocation: {enabled: true},
|
|
354
|
+
registration: {enabled: !!o.dynamicRegistration},
|
|
355
|
+
...(o.clientIdMetadataDocument ? {clientIdMetadataDocument: {
|
|
356
|
+
enabled: true,
|
|
357
|
+
ack: "draft-02",
|
|
358
|
+
allowFetch: async (_ctx: unknown, clientId: string) => isAllowedDocumentUrl(clientId, o.clientIdMetadataDocument!.allowedUrlPrefixes()),
|
|
359
|
+
}} : {}),
|
|
360
|
+
rpInitiatedLogout: {
|
|
361
|
+
enabled: true,
|
|
362
|
+
// Skip the provider's "do you want to sign out?" page. `logout=yes` is that page's
|
|
363
|
+
// button value: without it only the requesting client is signed out, not the session.
|
|
364
|
+
logoutSource: async (ctx, form) => {
|
|
365
|
+
if (!form.includes("</form>")) throw new Error("GGOAuthServer: oidc-provider's logout form changed; can't confirm logout")
|
|
366
|
+
// Any page can link here; only a first-party client's own page (its redirect origin as
|
|
367
|
+
// Referer) skips the confirmation click.
|
|
368
|
+
const client = ctx.oidc.client && firstParty.get(ctx.oidc.client.clientId)
|
|
369
|
+
const referer = URL.parse(ctx.get("referer"))?.origin
|
|
370
|
+
if (client && referer && client.redirectUris.some(u => new URL(u).origin === referer)) {
|
|
371
|
+
const confirmed = form.replace("</form>", `<input type="hidden" name="logout" value="yes"/></form>`)
|
|
372
|
+
ctx.body = `<!DOCTYPE html><html><body>${confirmed}<script>document.forms[0].submit()</script></body></html>`
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
ctx.body = `<!DOCTYPE html><html><body>${form.replace("</form>", `<button name="logout" value="yes">Sign out</button></form>`)}</body></html>`
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
resourceIndicators: {
|
|
379
|
+
enabled: true,
|
|
380
|
+
defaultResource: (_ctx, client) => this.defaultResource(client.clientId).resource(),
|
|
381
|
+
useGrantedResource: () => true,
|
|
382
|
+
getResourceServerInfo: (_ctx, indicator, client) => {
|
|
383
|
+
const resource = this.usableResource(indicator, client.clientId)
|
|
384
|
+
const jws = resource.accessToken.signer.jwsKey?.()
|
|
385
|
+
if (!jws) throw new Error("GGOAuthServer: a resource's accessToken signer must implement jwsKey() (e.g. HmacSigner)")
|
|
386
|
+
return {
|
|
387
|
+
scope: resource.scopes.join(" "),
|
|
388
|
+
audience: resource.accessToken.audience ?? indicator,
|
|
389
|
+
accessTokenTTL: Math.floor(resource.accessToken.accessTtlMs / 1000),
|
|
390
|
+
accessTokenFormat: "jwt",
|
|
391
|
+
jwt: {sign: {alg: jws.alg as "HS256", key: Buffer.from(jws.key)}},
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
// First-party clients never see a consent screen: an existing login session is enough.
|
|
397
|
+
loadExistingGrant: async (ctx) => {
|
|
398
|
+
const client = ctx.oidc.client!
|
|
399
|
+
const session = ctx.oidc.session!
|
|
400
|
+
const grantId = ctx.oidc.result?.consent?.grantId || session.grantIdFor(client.clientId)
|
|
401
|
+
if (grantId) return ctx.oidc.provider.Grant.find(grantId)
|
|
402
|
+
if (!firstParty.has(client.clientId) || !session.accountId) return undefined
|
|
403
|
+
const grant = new ctx.oidc.provider.Grant({accountId: session.accountId, clientId: client.clientId})
|
|
404
|
+
this.grantRequested(grant, ctx.oidc.params as Record<string, unknown>)
|
|
405
|
+
await grant.save()
|
|
406
|
+
session.ensureClientContainer(client.clientId)
|
|
407
|
+
session.grantIdFor(client.clientId, grant.jti)
|
|
408
|
+
return grant
|
|
409
|
+
},
|
|
410
|
+
// MCP clients don't reliably request offline_access; every code-flow client gets a refresh token.
|
|
411
|
+
issueRefreshToken: async (_ctx, client) => client.grantTypeAllowed("refresh_token"),
|
|
412
|
+
// Only first-party sign-ins end with the login session: signing out of the app must not
|
|
413
|
+
// disconnect the other apps the user connected (revoked explicitly instead).
|
|
414
|
+
expiresWithSession: async (ctx) => firstParty.has(ctx.oidc.client!.clientId),
|
|
415
|
+
// Runs before the presented refresh token is consumed. Resolve the claims here, so a failure
|
|
416
|
+
// (store/IdP down) leaves it redeemable — after consumption, the client's retry is reuse and
|
|
417
|
+
// revokes the grant.
|
|
418
|
+
rotateRefreshToken: async (ctx) => {
|
|
419
|
+
const rt = ctx.oidc.entities.RefreshToken!
|
|
420
|
+
const clientId = String(rt.clientId)
|
|
421
|
+
// Which resource the new access token will be for, as oidc-provider resolves it (the
|
|
422
|
+
// request's resource first, then the granted one), validated now rather than after consume.
|
|
423
|
+
const granted = [rt.resource ?? []].flat() as string[]
|
|
424
|
+
const requested = ctx.oidc.params?.resource
|
|
425
|
+
if (typeof requested === "string" && !granted.includes(requested)) throw new oidcErrors.InvalidTarget()
|
|
426
|
+
const indicator = typeof requested === "string" ? requested : granted.length === 1 ? granted[0] : undefined
|
|
427
|
+
const resource = indicator ? this.usableResource(indicator, clientId) : this.defaultResource(clientId)
|
|
428
|
+
const claims = await resource.claims(String(rt.accountId), this.clientOf(ctx))
|
|
429
|
+
if (!claims) throw new oidcErrors.InvalidGrant("subject is no longer valid")
|
|
430
|
+
prefetchedClaims.set(ctx, {resource: resource.resource(), claims})
|
|
431
|
+
return true
|
|
432
|
+
},
|
|
433
|
+
extraTokenClaims: async (_ctx: KoaContextWithOIDC, token) => {
|
|
434
|
+
if (token.kind !== "AccessToken") return undefined
|
|
435
|
+
const at = token as InstanceType<Provider["AccessToken"]>
|
|
436
|
+
const resource = this.resource(at.resourceServer?.identifier())
|
|
437
|
+
if (!resource) return undefined
|
|
438
|
+
const prefetched = prefetchedClaims.get(_ctx)
|
|
439
|
+
const claims = prefetched?.resource === resource.resource() ? prefetched.claims : await resource.claims(String(at.accountId), this.clientOf(_ctx))
|
|
440
|
+
if (!claims) throw new oidcErrors.InvalidGrant("subject is no longer valid")
|
|
441
|
+
return {data: resource.accessToken.encodeClaims(claims)}
|
|
442
|
+
},
|
|
443
|
+
findAccount: async (_ctx, sub) => ({accountId: sub, claims: async () => ({sub})}),
|
|
444
|
+
ttl: {
|
|
445
|
+
AccessToken: (_ctx, token) => {
|
|
446
|
+
const resource = this.resource(token.resourceServer?.identifier())
|
|
447
|
+
return resource ? Math.floor(resource.accessToken.accessTtlMs / 1000) : 600
|
|
448
|
+
},
|
|
449
|
+
AuthorizationCode: 60,
|
|
450
|
+
Interaction: 30 * 60,
|
|
451
|
+
Session: o.sessionTtlSec ?? 30 * 24 * 3600,
|
|
452
|
+
Grant: 365 * 24 * 3600,
|
|
453
|
+
RefreshToken: (ctx, token) => {
|
|
454
|
+
const rotated = ctx?.oidc.entities.RotatedRefreshToken
|
|
455
|
+
return rotated && !token.isSenderConstrained() ? rotated.remainingTTL : refreshTtlSec
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
renderError: async (ctx, out) => {
|
|
459
|
+
ctx.type = "text/plain"
|
|
460
|
+
ctx.body = `${out.error}: ${out.error_description ?? ""}`
|
|
461
|
+
},
|
|
462
|
+
}
|
|
463
|
+
const provider = new Provider(o.issuer(), config)
|
|
464
|
+
provider.proxy = !!o.proxy
|
|
465
|
+
// Resource scopes aren't OIDC scopes, so the provider leaves them out of its metadata.
|
|
466
|
+
provider.use(async (ctx, next) => {
|
|
467
|
+
await next()
|
|
468
|
+
if (ctx.oidc?.route === "discovery") (ctx.body as {scopes_supported: string[]}).scopes_supported = [...OIDC_SCOPES, ...new Set(o.resources.flatMap(r => r.scopes))]
|
|
469
|
+
})
|
|
470
|
+
this._provider = provider
|
|
471
|
+
return provider
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
function isAllowedDocumentUrl(clientId: string, prefixes: string[]): boolean {
|
|
477
|
+
const url = URL.parse(clientId)
|
|
478
|
+
if (!url || url.protocol !== "https:" || url.port !== "" || url.username || url.password) return false
|
|
479
|
+
return prefixes.some(prefix => (url.origin + url.pathname).startsWith(prefix))
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function requestedScopes(params: Record<string, unknown>): string[] {
|
|
483
|
+
return String(params.scope ?? "").split(" ").filter(Boolean)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function hkdf(secret: string, info: string): Buffer {
|
|
487
|
+
return Buffer.from(hkdfSync("sha256", secret, "", info, 32))
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Deterministic, so every instance publishes the same key without distributing a key file.
|
|
491
|
+
function deriveEs256Jwk(secret: string) {
|
|
492
|
+
const d = hkdf(secret, "@grest-ts/oauth es256")
|
|
493
|
+
const ecdh = createECDH("prime256v1")
|
|
494
|
+
ecdh.setPrivateKey(d)
|
|
495
|
+
const point = ecdh.getPublicKey()
|
|
496
|
+
const x = point.subarray(1, 33).toString("base64url")
|
|
497
|
+
const y = point.subarray(33).toString("base64url")
|
|
498
|
+
const kid = createHash("sha256").update(x + y).digest("base64url").slice(0, 16)
|
|
499
|
+
return {kty: "EC", crv: "P-256", d: d.toString("base64url"), x, y, alg: "ES256", use: "sig", kid}
|
|
500
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {IsNumber, IsObject, IsString} from "@grest-ts/schema"
|
|
2
|
+
import {type GGDynamoDb, GGDynamoDbTable} from "@grest-ts/db-dynamodb"
|
|
3
|
+
import {errors as oidcErrors} from "oidc-provider"
|
|
4
|
+
import type {GGOAuthPayload, GGOAuthStore} from "./GGOAuthStore"
|
|
5
|
+
|
|
6
|
+
const IsOAuthRow = IsObject({
|
|
7
|
+
pk: IsString.nonEmpty,
|
|
8
|
+
model: IsString.nonEmpty,
|
|
9
|
+
id: IsString.nonEmpty,
|
|
10
|
+
// oidc-provider's payload as JSON: opaque, nested, may hold undefined values.
|
|
11
|
+
payload: IsString,
|
|
12
|
+
grantId: IsString.orUndefined,
|
|
13
|
+
uid: IsString.orUndefined,
|
|
14
|
+
accountId: IsString.orUndefined,
|
|
15
|
+
/** Epoch seconds; set once (conditional write), so only one of two concurrent redemptions wins. */
|
|
16
|
+
consumedAt: IsNumber.min(0).orUndefined,
|
|
17
|
+
/** Epoch seconds; drives DynamoDB TTL. TTL deletion lags, so reads check it too. */
|
|
18
|
+
expiresAtSec: IsNumber.min(0).orUndefined,
|
|
19
|
+
})
|
|
20
|
+
type OAuthRow = typeof IsOAuthRow.infer
|
|
21
|
+
|
|
22
|
+
// Table to create (TTL attribute `expiresAtSec`): HASH `pk` (S), plus GSIs projecting ALL —
|
|
23
|
+
// `grantId-index` (grantId), `uid-index` (uid), `accountId-index` (accountId).
|
|
24
|
+
// accountId is written only for the models looked up by account, so the index stays small: every
|
|
25
|
+
// spent refresh token is kept until expiry (reuse detection) and would otherwise land in it.
|
|
26
|
+
const BY_ACCOUNT = new Set(["Grant", "Session"])
|
|
27
|
+
const REDEEMABLE = new Set(["AuthorizationCode", "RefreshToken"])
|
|
28
|
+
|
|
29
|
+
export class DynamoOAuthStore extends GGDynamoDbTable<OAuthRow, "pk"> implements GGOAuthStore {
|
|
30
|
+
|
|
31
|
+
constructor(db: GGDynamoDb, tableName: string) {
|
|
32
|
+
super(db, tableName, IsOAuthRow, "pk")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public upsert = async (model: string, id: string, payload: GGOAuthPayload, expiresInSec: number | undefined): Promise<void> => {
|
|
36
|
+
await this.put({
|
|
37
|
+
pk: `${model}#${id}`,
|
|
38
|
+
model,
|
|
39
|
+
id,
|
|
40
|
+
payload: JSON.stringify(payload),
|
|
41
|
+
grantId: payload.grantId,
|
|
42
|
+
uid: payload.uid,
|
|
43
|
+
accountId: BY_ACCOUNT.has(model) ? payload.accountId : undefined,
|
|
44
|
+
expiresAtSec: expiresInSec ? Math.floor(Date.now() / 1000) + expiresInSec : undefined,
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public find = async (model: string, id: string): Promise<GGOAuthPayload | undefined> => this.payloadOf(await this.get(`${model}#${id}`))
|
|
49
|
+
|
|
50
|
+
public findByUid = async (model: string, uid: string): Promise<GGOAuthPayload | undefined> =>
|
|
51
|
+
this.payloadOf(await this.queryOne("uid-index", "uid = :uid", {":uid": uid, ":model": model}, {filter: "#m = :model", names: {"#m": "model"}}))
|
|
52
|
+
|
|
53
|
+
public consume = async (model: string, id: string): Promise<void> => {
|
|
54
|
+
const row = await this.get(`${model}#${id}`)
|
|
55
|
+
if (!row) {
|
|
56
|
+
// A code or refresh token revoked since it was read (e.g. the app was disconnected) must not be redeemed.
|
|
57
|
+
if (REDEEMABLE.has(model)) throw new oidcErrors.InvalidGrant(`${model} revoked`)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
const consumedAt = Math.floor(Date.now() / 1000)
|
|
61
|
+
const payload = JSON.stringify({...JSON.parse(row.payload), consumed: consumedAt})
|
|
62
|
+
// attribute_exists(pk): a row revoked meanwhile stays gone instead of being re-created.
|
|
63
|
+
if (!await this.putConditional({...row, payload, consumedAt}, "attribute_exists(pk) AND attribute_not_exists(consumedAt)")) {
|
|
64
|
+
throw new oidcErrors.InvalidGrant(`${model} already used`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public destroy = async (model: string, id: string): Promise<void> => {
|
|
69
|
+
await this.delete(`${model}#${id}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A long-lived grant accumulates one spent refresh token per rotation (kept for reuse detection), so page.
|
|
73
|
+
public revokeByGrantId = async (grantId: string): Promise<void> => {
|
|
74
|
+
let cursor: string | undefined
|
|
75
|
+
do {
|
|
76
|
+
const page = await this.queryPage("grantId-index", "grantId = :g", {":g": grantId}, {cursor, limit: 100})
|
|
77
|
+
await Promise.all(page.items.map(row => this.delete(row.pk)))
|
|
78
|
+
cursor = page.cursor
|
|
79
|
+
} while (cursor)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
public listIdsByAccount = async (model: string, accountId: string): Promise<string[]> =>
|
|
83
|
+
(await this.query("accountId-index", "accountId = :a", {":a": accountId, ":model": model}, {filter: "#m = :model", names: {"#m": "model"}}))
|
|
84
|
+
.filter(row => this.live(row)).map(row => row.id)
|
|
85
|
+
|
|
86
|
+
private payloadOf(row: OAuthRow | undefined): GGOAuthPayload | undefined {
|
|
87
|
+
return row && this.live(row) ? JSON.parse(row.payload) as GGOAuthPayload : undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private live(row: OAuthRow): boolean {
|
|
91
|
+
return row.expiresAtSec === undefined || row.expiresAtSec > Date.now() / 1000
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type {AdapterConstructor} from "oidc-provider"
|
|
2
|
+
|
|
3
|
+
// oidc-provider model state (sessions, grants, codes, refresh tokens, registered clients,
|
|
4
|
+
// interactions). Payloads are opaque JSON owned by oidc-provider; expiry is enforced by the store.
|
|
5
|
+
export type GGOAuthPayload = Record<string, unknown> & {
|
|
6
|
+
grantId?: string
|
|
7
|
+
uid?: string
|
|
8
|
+
accountId?: string
|
|
9
|
+
consumed?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface GGOAuthStore {
|
|
13
|
+
upsert(model: string, id: string, payload: GGOAuthPayload, expiresInSec: number | undefined): Promise<void>
|
|
14
|
+
find(model: string, id: string): Promise<GGOAuthPayload | undefined>
|
|
15
|
+
findByUid(model: string, uid: string): Promise<GGOAuthPayload | undefined>
|
|
16
|
+
// Mark consumed (not delete): a second use of a code / rotated refresh token is detected as replay.
|
|
17
|
+
// Must be atomic — throw oidc-provider's InvalidGrant if it was already consumed, so of two
|
|
18
|
+
// concurrent redemptions one fails cleanly (a 5xx would be retried, and the retry is reuse).
|
|
19
|
+
consume(model: string, id: string): Promise<void>
|
|
20
|
+
destroy(model: string, id: string): Promise<void>
|
|
21
|
+
// Every model row carrying this grantId (codes, refresh tokens, the grant itself).
|
|
22
|
+
revokeByGrantId(grantId: string): Promise<void>
|
|
23
|
+
// Ids of this account's rows of one model (Grant, Session) — for "connected apps" and revoke-all.
|
|
24
|
+
listIdsByAccount(model: string, accountId: string): Promise<string[]>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// oidc-provider's adapter shape: `new Adapter(modelName)` per model.
|
|
28
|
+
export function toOidcAdapter(store: GGOAuthStore): AdapterConstructor {
|
|
29
|
+
return class GGOAuthAdapter {
|
|
30
|
+
private readonly model: string
|
|
31
|
+
constructor(model: string) { this.model = model }
|
|
32
|
+
upsert = (id: string, payload: GGOAuthPayload, expiresIn?: number) => store.upsert(this.model, id, payload, expiresIn)
|
|
33
|
+
find = (id: string) => store.find(this.model, id)
|
|
34
|
+
findByUid = (uid: string) => store.findByUid(this.model, uid)
|
|
35
|
+
// Device flow is never enabled.
|
|
36
|
+
findByUserCode = async () => undefined
|
|
37
|
+
consume = (id: string) => store.consume(this.model, id)
|
|
38
|
+
destroy = (id: string) => store.destroy(this.model, id)
|
|
39
|
+
revokeByGrantId = (grantId: string) => store.revokeByGrantId(grantId)
|
|
40
|
+
}
|
|
41
|
+
}
|