@stravigor/oauth2 0.4.5

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.
@@ -0,0 +1,217 @@
1
+ import { inject } from '@stravigor/core/core'
2
+ import Configuration from '@stravigor/core/config/configuration'
3
+ import Database from '@stravigor/core/database/database'
4
+ import Router from '@stravigor/core/http/router'
5
+ import { compose } from '@stravigor/core/http/middleware'
6
+ import type { Handler, Middleware } from '@stravigor/core/http/middleware'
7
+ import { rateLimit } from '@stravigor/core/http/rate_limit'
8
+ import { auth } from '@stravigor/core/auth/middleware/authenticate'
9
+ import { csrf } from '@stravigor/core/auth/middleware/csrf'
10
+ import { ConfigurationError } from '@stravigor/core/exceptions/errors'
11
+ import ScopeRegistry from './scopes.ts'
12
+ import type { OAuth2Actions, OAuth2Config } from './types.ts'
13
+ import { authorizeHandler, approveHandler } from './handlers/authorize.ts'
14
+ import { tokenHandler } from './handlers/token.ts'
15
+ import { revokeHandler } from './handlers/revoke.ts'
16
+ import { introspectHandler } from './handlers/introspect.ts'
17
+ import { listClientsHandler, createClientHandler, deleteClientHandler } from './handlers/clients.ts'
18
+ import {
19
+ createPersonalTokenHandler,
20
+ listPersonalTokensHandler,
21
+ revokePersonalTokenHandler,
22
+ } from './handlers/personal_tokens.ts'
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Default config
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const DEFAULTS: OAuth2Config = {
29
+ accessTokenLifetime: 60,
30
+ refreshTokenLifetime: 43_200,
31
+ authCodeLifetime: 10,
32
+ personalAccessTokenLifetime: 525_600,
33
+ prefix: '/oauth',
34
+ scopes: {},
35
+ defaultScopes: [],
36
+ personalAccessClient: null,
37
+ rateLimit: {
38
+ authorize: { max: 30, window: 60 },
39
+ token: { max: 20, window: 60 },
40
+ },
41
+ pruneRevokedAfterDays: 7,
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Manager
46
+ // ---------------------------------------------------------------------------
47
+
48
+ function withMiddleware(mw: Middleware[], handler: Handler): Handler {
49
+ return mw.length > 0 ? compose(mw, handler) : handler
50
+ }
51
+
52
+ @inject
53
+ export default class OAuth2Manager {
54
+ private static _config: OAuth2Config
55
+ private static _db: Database
56
+ private static _actions: OAuth2Actions
57
+
58
+ constructor(db: Database, config: Configuration) {
59
+ const raw = config.get('oauth2', {}) as Partial<OAuth2Config>
60
+ OAuth2Manager._db = db
61
+ OAuth2Manager._config = { ...DEFAULTS, ...raw } as OAuth2Config
62
+
63
+ // Register scopes from config
64
+ if (OAuth2Manager._config.scopes) {
65
+ ScopeRegistry.define(OAuth2Manager._config.scopes)
66
+ }
67
+ }
68
+
69
+ // ── Accessors ────────────────────────────────────────────────────────
70
+
71
+ static get config(): OAuth2Config {
72
+ if (!OAuth2Manager._config) {
73
+ throw new ConfigurationError(
74
+ 'OAuth2Manager not configured. Resolve it through the container first.'
75
+ )
76
+ }
77
+ return OAuth2Manager._config
78
+ }
79
+
80
+ static get db(): Database {
81
+ if (!OAuth2Manager._db) {
82
+ throw new ConfigurationError(
83
+ 'OAuth2Manager not configured. Resolve it through the container first.'
84
+ )
85
+ }
86
+ return OAuth2Manager._db
87
+ }
88
+
89
+ static get actions(): OAuth2Actions {
90
+ if (!OAuth2Manager._actions) {
91
+ throw new ConfigurationError('OAuth2 actions not set. Pass actions to OAuth2Provider.')
92
+ }
93
+ return OAuth2Manager._actions
94
+ }
95
+
96
+ /** Set the user-defined actions contract. */
97
+ static useActions(actions: OAuth2Actions): void {
98
+ OAuth2Manager._actions = actions
99
+ }
100
+
101
+ // ── Table management ─────────────────────────────────────────────────
102
+
103
+ /** Create the required database tables if they don't exist. */
104
+ static async ensureTables(): Promise<void> {
105
+ const db = OAuth2Manager.db
106
+
107
+ await db.sql`
108
+ CREATE TABLE IF NOT EXISTS "_strav_oauth_clients" (
109
+ "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
110
+ "name" VARCHAR(255) NOT NULL,
111
+ "secret" VARCHAR(255),
112
+ "redirect_uris" JSONB NOT NULL DEFAULT '[]',
113
+ "scopes" JSONB,
114
+ "grant_types" JSONB NOT NULL DEFAULT '[]',
115
+ "confidential" BOOLEAN NOT NULL DEFAULT true,
116
+ "first_party" BOOLEAN NOT NULL DEFAULT false,
117
+ "revoked" BOOLEAN NOT NULL DEFAULT false,
118
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
119
+ "updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
120
+ )
121
+ `
122
+
123
+ await db.sql`
124
+ CREATE TABLE IF NOT EXISTS "_strav_oauth_tokens" (
125
+ "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
126
+ "user_id" VARCHAR(255),
127
+ "client_id" UUID NOT NULL REFERENCES "_strav_oauth_clients"("id") ON DELETE CASCADE,
128
+ "name" VARCHAR(255),
129
+ "scopes" JSONB NOT NULL DEFAULT '[]',
130
+ "token" VARCHAR(255) NOT NULL UNIQUE,
131
+ "refresh_token" VARCHAR(255) UNIQUE,
132
+ "expires_at" TIMESTAMPTZ NOT NULL,
133
+ "refresh_expires_at" TIMESTAMPTZ,
134
+ "last_used_at" TIMESTAMPTZ,
135
+ "revoked_at" TIMESTAMPTZ,
136
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
137
+ )
138
+ `
139
+
140
+ await db.sql`
141
+ CREATE TABLE IF NOT EXISTS "_strav_oauth_auth_codes" (
142
+ "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
143
+ "client_id" UUID NOT NULL REFERENCES "_strav_oauth_clients"("id") ON DELETE CASCADE,
144
+ "user_id" VARCHAR(255) NOT NULL,
145
+ "code" VARCHAR(255) NOT NULL UNIQUE,
146
+ "redirect_uri" VARCHAR(2048) NOT NULL,
147
+ "scopes" JSONB NOT NULL DEFAULT '[]',
148
+ "code_challenge" VARCHAR(255),
149
+ "code_challenge_method" VARCHAR(10),
150
+ "expires_at" TIMESTAMPTZ NOT NULL,
151
+ "used_at" TIMESTAMPTZ,
152
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
153
+ )
154
+ `
155
+
156
+ // Indexes for common queries
157
+ await db.sql`
158
+ CREATE INDEX IF NOT EXISTS "idx_oauth_tokens_user_id"
159
+ ON "_strav_oauth_tokens" ("user_id")
160
+ `
161
+ await db.sql`
162
+ CREATE INDEX IF NOT EXISTS "idx_oauth_tokens_client_id"
163
+ ON "_strav_oauth_tokens" ("client_id")
164
+ `
165
+ await db.sql`
166
+ CREATE INDEX IF NOT EXISTS "idx_oauth_auth_codes_client_id"
167
+ ON "_strav_oauth_auth_codes" ("client_id")
168
+ `
169
+ }
170
+
171
+ // ── Route registration ───────────────────────────────────────────────
172
+
173
+ private static rl(key: keyof OAuth2Config['rateLimit']): Middleware {
174
+ const cfg = OAuth2Manager._config.rateLimit[key]
175
+ return rateLimit({ max: cfg.max, window: cfg.window * 1000 })
176
+ }
177
+
178
+ /**
179
+ * Register all OAuth2 routes on the given router.
180
+ */
181
+ static routes(router: Router): void {
182
+ const prefix = OAuth2Manager._config.prefix
183
+
184
+ router.group({ prefix }, r => {
185
+ // Authorization code flow
186
+ r.get('/authorize', withMiddleware([auth(), OAuth2Manager.rl('authorize')], authorizeHandler))
187
+ r.post('/authorize', withMiddleware([auth(), csrf()], approveHandler))
188
+
189
+ // Token endpoint (all grant types)
190
+ r.post('/token', withMiddleware([OAuth2Manager.rl('token')], tokenHandler))
191
+
192
+ // Revocation (RFC 7009)
193
+ r.post('/revoke', revokeHandler)
194
+
195
+ // Introspection (RFC 7662)
196
+ r.post('/introspect', introspectHandler)
197
+
198
+ // Client management
199
+ r.get('/clients', withMiddleware([auth()], listClientsHandler))
200
+ r.post('/clients', withMiddleware([auth()], createClientHandler))
201
+ r.delete('/clients/:id', withMiddleware([auth()], deleteClientHandler))
202
+
203
+ // Personal access tokens
204
+ r.post('/personal-tokens', withMiddleware([auth()], createPersonalTokenHandler))
205
+ r.get('/personal-tokens', withMiddleware([auth()], listPersonalTokensHandler))
206
+ r.delete('/personal-tokens/:id', withMiddleware([auth()], revokePersonalTokenHandler))
207
+ })
208
+ }
209
+
210
+ /** Clear all state. For testing. */
211
+ static reset(): void {
212
+ OAuth2Manager._config = undefined as any
213
+ OAuth2Manager._db = undefined as any
214
+ OAuth2Manager._actions = undefined as any
215
+ ScopeRegistry.reset()
216
+ }
217
+ }
@@ -0,0 +1,29 @@
1
+ import { ServiceProvider } from '@stravigor/core/core'
2
+ import type { Application } from '@stravigor/core/core'
3
+ import Router from '@stravigor/core/http/router'
4
+ import OAuth2Manager from './oauth2_manager.ts'
5
+ import type { OAuth2Actions } from './types.ts'
6
+
7
+ export default class OAuth2Provider extends ServiceProvider {
8
+ readonly name = 'oauth2'
9
+ override readonly dependencies = ['auth', 'session', 'encryption', 'database']
10
+
11
+ constructor(private actions: OAuth2Actions) {
12
+ super()
13
+ }
14
+
15
+ override register(app: Application): void {
16
+ app.singleton(OAuth2Manager)
17
+ }
18
+
19
+ override async boot(app: Application): Promise<void> {
20
+ app.resolve(OAuth2Manager)
21
+ OAuth2Manager.useActions(this.actions)
22
+ await OAuth2Manager.ensureTables()
23
+ OAuth2Manager.routes(app.resolve(Router))
24
+ }
25
+
26
+ override shutdown(): void {
27
+ OAuth2Manager.reset()
28
+ }
29
+ }
package/src/scopes.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { InvalidScopeError } from './errors.ts'
2
+ import type { ScopeDescription } from './types.ts'
3
+
4
+ /**
5
+ * Scope registry — manages available OAuth2 scopes and their descriptions.
6
+ *
7
+ * Scopes are loaded from config on boot and can be extended at runtime.
8
+ */
9
+ export default class ScopeRegistry {
10
+ private static _scopes = new Map<string, string>()
11
+
12
+ /** Register scopes from a name→description record. */
13
+ static define(scopes: Record<string, string>): void {
14
+ for (const [name, description] of Object.entries(scopes)) {
15
+ ScopeRegistry._scopes.set(name, description)
16
+ }
17
+ }
18
+
19
+ /** Check if a scope is registered. */
20
+ static has(name: string): boolean {
21
+ return ScopeRegistry._scopes.has(name)
22
+ }
23
+
24
+ /** Get all registered scopes. */
25
+ static all(): ScopeDescription[] {
26
+ return Array.from(ScopeRegistry._scopes.entries()).map(([name, description]) => ({
27
+ name,
28
+ description,
29
+ }))
30
+ }
31
+
32
+ /** Get descriptions for a list of scope names. */
33
+ static describe(names: string[]): ScopeDescription[] {
34
+ return names.map(name => ({
35
+ name,
36
+ description: ScopeRegistry._scopes.get(name) ?? name,
37
+ }))
38
+ }
39
+
40
+ /**
41
+ * Validate requested scopes against registered scopes and client-allowed scopes.
42
+ *
43
+ * - If no scopes are requested, returns defaultScopes.
44
+ * - Throws InvalidScopeError if a scope is not registered.
45
+ * - Throws InvalidScopeError if a scope is not allowed for the client.
46
+ */
47
+ static validate(
48
+ requested: string[],
49
+ clientAllowed: string[] | null,
50
+ defaultScopes: string[]
51
+ ): string[] {
52
+ const scopes = requested.length > 0 ? requested : defaultScopes
53
+ if (scopes.length === 0) return []
54
+
55
+ for (const scope of scopes) {
56
+ if (!ScopeRegistry._scopes.has(scope)) {
57
+ throw new InvalidScopeError(`Unknown scope: "${scope}".`)
58
+ }
59
+ }
60
+
61
+ // If client has restricted scopes, ensure all requested are allowed
62
+ if (clientAllowed !== null) {
63
+ for (const scope of scopes) {
64
+ if (!clientAllowed.includes(scope)) {
65
+ throw new InvalidScopeError(`Scope "${scope}" is not allowed for this client.`)
66
+ }
67
+ }
68
+ }
69
+
70
+ return scopes
71
+ }
72
+
73
+ /** Clear all registered scopes. For testing. */
74
+ static reset(): void {
75
+ ScopeRegistry._scopes.clear()
76
+ }
77
+ }
package/src/token.ts ADDED
@@ -0,0 +1,231 @@
1
+ import OAuth2Manager from './oauth2_manager.ts'
2
+ import { randomHex } from '@stravigor/core/helpers'
3
+ import type { OAuthTokenData } from './types.ts'
4
+
5
+ function hashToken(plain: string): string {
6
+ return new Bun.CryptoHasher('sha256').update(plain).digest('hex')
7
+ }
8
+
9
+ /**
10
+ * Static helper for managing OAuth2 tokens.
11
+ *
12
+ * Access tokens and refresh tokens are SHA-256 hashed before storage.
13
+ * The plain-text tokens are returned exactly once at creation time.
14
+ *
15
+ * @example
16
+ * const { accessToken, refreshToken, tokenData } = await OAuthToken.create({ ... })
17
+ * const record = await OAuthToken.validate(plainAccessToken)
18
+ * await OAuthToken.revoke(tokenId)
19
+ */
20
+ export default class OAuthToken {
21
+ /**
22
+ * Issue a new access token (and optionally a refresh token).
23
+ * Returns plain-text tokens (shown once) and the database record.
24
+ */
25
+ static async create(params: {
26
+ userId: string | null
27
+ clientId: string
28
+ scopes: string[]
29
+ name?: string | null
30
+ includeRefreshToken?: boolean
31
+ accessTokenLifetime?: number // minutes
32
+ refreshTokenLifetime?: number // minutes
33
+ }): Promise<{
34
+ accessToken: string
35
+ refreshToken: string | null
36
+ tokenData: OAuthTokenData
37
+ }> {
38
+ const config = OAuth2Manager.config
39
+
40
+ const plainAccess = randomHex(40)
41
+ const hashedAccess = hashToken(plainAccess)
42
+
43
+ const accessLifetime = params.accessTokenLifetime ?? config.accessTokenLifetime
44
+ const expiresAt = new Date(Date.now() + accessLifetime * 60_000)
45
+
46
+ let plainRefresh: string | null = null
47
+ let hashedRefresh: string | null = null
48
+ let refreshExpiresAt: Date | null = null
49
+
50
+ if (params.includeRefreshToken !== false && params.userId !== null) {
51
+ const refreshLifetime = params.refreshTokenLifetime ?? config.refreshTokenLifetime
52
+ plainRefresh = randomHex(40)
53
+ hashedRefresh = hashToken(plainRefresh)
54
+ refreshExpiresAt = new Date(Date.now() + refreshLifetime * 60_000)
55
+ }
56
+
57
+ const rows = await OAuth2Manager.db.sql`
58
+ INSERT INTO "_strav_oauth_tokens" (
59
+ "user_id", "client_id", "name", "scopes", "token",
60
+ "refresh_token", "expires_at", "refresh_expires_at"
61
+ )
62
+ VALUES (
63
+ ${params.userId},
64
+ ${params.clientId},
65
+ ${params.name ?? null},
66
+ ${JSON.stringify(params.scopes)},
67
+ ${hashedAccess},
68
+ ${hashedRefresh},
69
+ ${expiresAt},
70
+ ${refreshExpiresAt}
71
+ )
72
+ RETURNING *
73
+ `
74
+
75
+ return {
76
+ accessToken: plainAccess,
77
+ refreshToken: plainRefresh,
78
+ tokenData: OAuthToken.hydrate(rows[0] as Record<string, unknown>),
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Validate a plain-text access token.
84
+ * Returns the token record if valid, null if invalid/expired/revoked.
85
+ * Updates `last_used_at` (fire-and-forget).
86
+ */
87
+ static async validate(plainToken: string): Promise<OAuthTokenData | null> {
88
+ const hash = hashToken(plainToken)
89
+
90
+ const rows = await OAuth2Manager.db.sql`
91
+ SELECT * FROM "_strav_oauth_tokens"
92
+ WHERE "token" = ${hash} LIMIT 1
93
+ `
94
+ if (rows.length === 0) return null
95
+
96
+ const record = OAuthToken.hydrate(rows[0] as Record<string, unknown>)
97
+
98
+ // Reject revoked tokens
99
+ if (record.revokedAt) return null
100
+
101
+ // Reject expired tokens
102
+ if (record.expiresAt.getTime() < Date.now()) return null
103
+
104
+ // Update last_used_at (fire-and-forget)
105
+ OAuth2Manager.db.sql`
106
+ UPDATE "_strav_oauth_tokens"
107
+ SET "last_used_at" = NOW()
108
+ WHERE "id" = ${record.id}
109
+ `.then(
110
+ () => {},
111
+ () => {}
112
+ )
113
+
114
+ return record
115
+ }
116
+
117
+ /**
118
+ * Validate a plain-text refresh token.
119
+ * Returns the token record if valid, null if invalid/expired/revoked.
120
+ */
121
+ static async validateRefreshToken(plainRefresh: string): Promise<OAuthTokenData | null> {
122
+ const hash = hashToken(plainRefresh)
123
+
124
+ const rows = await OAuth2Manager.db.sql`
125
+ SELECT * FROM "_strav_oauth_tokens"
126
+ WHERE "refresh_token" = ${hash} LIMIT 1
127
+ `
128
+ if (rows.length === 0) return null
129
+
130
+ const record = OAuthToken.hydrate(rows[0] as Record<string, unknown>)
131
+
132
+ // Reject revoked tokens
133
+ if (record.revokedAt) return null
134
+
135
+ // Reject expired refresh tokens
136
+ if (record.refreshExpiresAt && record.refreshExpiresAt.getTime() < Date.now()) return null
137
+
138
+ return record
139
+ }
140
+
141
+ /** Revoke a token by ID (soft-revoke with timestamp). */
142
+ static async revoke(id: string): Promise<void> {
143
+ await OAuth2Manager.db.sql`
144
+ UPDATE "_strav_oauth_tokens"
145
+ SET "revoked_at" = NOW()
146
+ WHERE "id" = ${id}
147
+ `
148
+ }
149
+
150
+ /** Revoke all tokens for a user. */
151
+ static async revokeAllFor(userId: string): Promise<void> {
152
+ await OAuth2Manager.db.sql`
153
+ UPDATE "_strav_oauth_tokens"
154
+ SET "revoked_at" = NOW()
155
+ WHERE "user_id" = ${userId} AND "revoked_at" IS NULL
156
+ `
157
+ }
158
+
159
+ /** Revoke all tokens for a user on a specific client. */
160
+ static async revokeAllForClient(userId: string, clientId: string): Promise<void> {
161
+ await OAuth2Manager.db.sql`
162
+ UPDATE "_strav_oauth_tokens"
163
+ SET "revoked_at" = NOW()
164
+ WHERE "user_id" = ${userId} AND "client_id" = ${clientId} AND "revoked_at" IS NULL
165
+ `
166
+ }
167
+
168
+ /** List all active tokens for a user. */
169
+ static async allForUser(userId: string): Promise<OAuthTokenData[]> {
170
+ const rows = await OAuth2Manager.db.sql`
171
+ SELECT * FROM "_strav_oauth_tokens"
172
+ WHERE "user_id" = ${userId} AND "revoked_at" IS NULL AND "expires_at" > NOW()
173
+ ORDER BY "created_at" DESC
174
+ `
175
+ return (rows as Record<string, unknown>[]).map(OAuthToken.hydrate)
176
+ }
177
+
178
+ /** List personal access tokens for a user. */
179
+ static async personalTokensFor(userId: string): Promise<OAuthTokenData[]> {
180
+ const patClientId = OAuth2Manager.config.personalAccessClient
181
+ if (!patClientId) return []
182
+
183
+ const rows = await OAuth2Manager.db.sql`
184
+ SELECT * FROM "_strav_oauth_tokens"
185
+ WHERE "user_id" = ${userId}
186
+ AND "client_id" = ${patClientId}
187
+ AND "revoked_at" IS NULL
188
+ AND "expires_at" > NOW()
189
+ ORDER BY "created_at" DESC
190
+ `
191
+ return (rows as Record<string, unknown>[]).map(OAuthToken.hydrate)
192
+ }
193
+
194
+ /** Prune expired and old revoked tokens. Returns the number of deleted rows. */
195
+ static async prune(revokedOlderThanDays: number): Promise<number> {
196
+ const cutoff = new Date(Date.now() - revokedOlderThanDays * 86_400_000)
197
+
198
+ const result = await OAuth2Manager.db.sql`
199
+ DELETE FROM "_strav_oauth_tokens"
200
+ WHERE ("expires_at" < NOW() AND "refresh_expires_at" IS NULL)
201
+ OR ("refresh_expires_at" IS NOT NULL AND "refresh_expires_at" < NOW())
202
+ OR ("revoked_at" IS NOT NULL AND "revoked_at" < ${cutoff})
203
+ `
204
+ return result.count ?? 0
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Internal
209
+ // ---------------------------------------------------------------------------
210
+
211
+ static hydrate(row: Record<string, unknown>): OAuthTokenData {
212
+ return {
213
+ id: String(row.id),
214
+ userId: row.user_id as string | null,
215
+ clientId: String(row.client_id),
216
+ name: (row.name as string) ?? null,
217
+ scopes: parseJsonb(row.scopes) as string[],
218
+ expiresAt: row.expires_at as Date,
219
+ refreshExpiresAt: (row.refresh_expires_at as Date) ?? null,
220
+ lastUsedAt: (row.last_used_at as Date) ?? null,
221
+ revokedAt: (row.revoked_at as Date) ?? null,
222
+ createdAt: row.created_at as Date,
223
+ }
224
+ }
225
+ }
226
+
227
+ function parseJsonb(value: unknown): unknown {
228
+ if (value === null || value === undefined) return []
229
+ if (typeof value === 'string') return JSON.parse(value)
230
+ return value
231
+ }
package/src/types.ts ADDED
@@ -0,0 +1,137 @@
1
+ import type Context from '@stravigor/core/http/context'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Grant types
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export type GrantType = 'authorization_code' | 'client_credentials' | 'refresh_token'
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Actions — the user-provided contract
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export interface OAuth2Actions<TUser = unknown> {
14
+ /** Find a user by primary key. Used to load the resource owner. */
15
+ findById(id: string | number): Promise<TUser | null>
16
+
17
+ /** Extract the user's display identifier (shown on consent screen). */
18
+ identifierOf(user: TUser): string
19
+
20
+ /**
21
+ * Render the consent/authorization screen for third-party clients.
22
+ * Return a Response (HTML page, view render, or redirect to your SPA).
23
+ *
24
+ * When not provided, the handler returns a JSON payload with the
25
+ * authorization details so an SPA can render its own UI.
26
+ */
27
+ renderAuthorization?(
28
+ ctx: Context,
29
+ client: OAuthClientData,
30
+ scopes: ScopeDescription[]
31
+ ): Promise<Response>
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Data types
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export interface OAuthClientData {
39
+ id: string
40
+ name: string
41
+ redirectUris: string[]
42
+ scopes: string[] | null
43
+ grantTypes: GrantType[]
44
+ confidential: boolean
45
+ firstParty: boolean
46
+ revoked: boolean
47
+ createdAt: Date
48
+ updatedAt: Date
49
+ }
50
+
51
+ export interface OAuthTokenData {
52
+ id: string
53
+ userId: string | null
54
+ clientId: string
55
+ name: string | null
56
+ scopes: string[]
57
+ expiresAt: Date
58
+ refreshExpiresAt: Date | null
59
+ lastUsedAt: Date | null
60
+ revokedAt: Date | null
61
+ createdAt: Date
62
+ }
63
+
64
+ export interface OAuthAuthCodeData {
65
+ id: string
66
+ clientId: string
67
+ userId: string
68
+ redirectUri: string
69
+ scopes: string[]
70
+ codeChallenge: string | null
71
+ codeChallengeMethod: string | null
72
+ expiresAt: Date
73
+ usedAt: Date | null
74
+ createdAt: Date
75
+ }
76
+
77
+ export interface ScopeDescription {
78
+ name: string
79
+ description: string
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Input types
84
+ // ---------------------------------------------------------------------------
85
+
86
+ export interface CreateClientInput {
87
+ name: string
88
+ redirectUris: string[]
89
+ confidential?: boolean
90
+ firstParty?: boolean
91
+ scopes?: string[] | null
92
+ grantTypes?: GrantType[]
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Configuration
97
+ // ---------------------------------------------------------------------------
98
+
99
+ export interface RateLimitConfig {
100
+ max: number
101
+ window: number // seconds
102
+ }
103
+
104
+ export interface OAuth2Config {
105
+ accessTokenLifetime: number // minutes
106
+ refreshTokenLifetime: number // minutes
107
+ authCodeLifetime: number // minutes
108
+ personalAccessTokenLifetime: number // minutes
109
+ prefix: string
110
+ scopes: Record<string, string>
111
+ defaultScopes: string[]
112
+ personalAccessClient: string | null
113
+ rateLimit: {
114
+ authorize: RateLimitConfig
115
+ token: RateLimitConfig
116
+ }
117
+ pruneRevokedAfterDays: number
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Events
122
+ // ---------------------------------------------------------------------------
123
+
124
+ export interface OAuth2Event {
125
+ ctx?: Context
126
+ [key: string]: unknown
127
+ }
128
+
129
+ export const OAuth2Events = {
130
+ TOKEN_ISSUED: 'oauth2:token-issued',
131
+ TOKEN_REVOKED: 'oauth2:token-revoked',
132
+ TOKEN_REFRESHED: 'oauth2:token-refreshed',
133
+ CODE_ISSUED: 'oauth2:code-issued',
134
+ CLIENT_CREATED: 'oauth2:client-created',
135
+ CLIENT_REVOKED: 'oauth2:client-revoked',
136
+ ACCESS_DENIED: 'oauth2:access-denied',
137
+ } as const