@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.
- package/README.md +72 -0
- package/package.json +27 -0
- package/src/actions.ts +20 -0
- package/src/auth_code.ts +154 -0
- package/src/client.ts +155 -0
- package/src/commands/oauth2_commands.ts +152 -0
- package/src/errors.ts +62 -0
- package/src/handlers/authorize.ts +243 -0
- package/src/handlers/clients.ts +101 -0
- package/src/handlers/introspect.ts +61 -0
- package/src/handlers/personal_tokens.ts +115 -0
- package/src/handlers/revoke.ts +71 -0
- package/src/handlers/token.ts +290 -0
- package/src/helpers.ts +98 -0
- package/src/index.ts +59 -0
- package/src/middleware/oauth.ts +61 -0
- package/src/middleware/scopes.ts +37 -0
- package/src/oauth2_manager.ts +217 -0
- package/src/oauth2_provider.ts +29 -0
- package/src/scopes.ts +77 -0
- package/src/token.ts +231 -0
- package/src/types.ts +137 -0
- package/src/utils.ts +15 -0
- package/stubs/actions/oauth2.ts +28 -0
- package/stubs/config/oauth2.ts +35 -0
- package/stubs/schemas/oauth_auth_code.ts +16 -0
- package/stubs/schemas/oauth_client.ts +15 -0
- package/stubs/schemas/oauth_token.ts +17 -0
- package/tsconfig.json +4 -0
|
@@ -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
|