@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,290 @@
1
+ import type Context from '@stravigor/core/http/context'
2
+ import Emitter from '@stravigor/core/events/emitter'
3
+ import OAuth2Manager from '../oauth2_manager.ts'
4
+ import OAuthClient from '../client.ts'
5
+ import OAuthToken from '../token.ts'
6
+ import AuthCode from '../auth_code.ts'
7
+ import ScopeRegistry from '../scopes.ts'
8
+ import { OAuth2Events } from '../types.ts'
9
+ import {
10
+ InvalidClientError,
11
+ InvalidGrantError,
12
+ InvalidRequestError,
13
+ UnsupportedGrantError,
14
+ } from '../errors.ts'
15
+
16
+ /**
17
+ * POST /oauth/token
18
+ *
19
+ * Token endpoint — handles all grant types:
20
+ * - authorization_code (+ PKCE)
21
+ * - client_credentials
22
+ * - refresh_token
23
+ */
24
+ export async function tokenHandler(ctx: Context): Promise<Response> {
25
+ const body = await ctx.body<Record<string, string>>()
26
+ const grantType = body.grant_type
27
+
28
+ if (!grantType) {
29
+ return errorResponse(ctx, new InvalidRequestError('The grant_type parameter is required.'))
30
+ }
31
+
32
+ switch (grantType) {
33
+ case 'authorization_code':
34
+ return handleAuthorizationCode(ctx, body)
35
+ case 'client_credentials':
36
+ return handleClientCredentials(ctx, body)
37
+ case 'refresh_token':
38
+ return handleRefreshToken(ctx, body)
39
+ default:
40
+ return errorResponse(ctx, new UnsupportedGrantError())
41
+ }
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Authorization Code Grant
46
+ // ---------------------------------------------------------------------------
47
+
48
+ async function handleAuthorizationCode(
49
+ ctx: Context,
50
+ body: Record<string, string>
51
+ ): Promise<Response> {
52
+ const { code, redirect_uri, client_id, client_secret, code_verifier } = body
53
+
54
+ if (!code || !redirect_uri || !client_id) {
55
+ return errorResponse(
56
+ ctx,
57
+ new InvalidRequestError('The code, redirect_uri, and client_id parameters are required.')
58
+ )
59
+ }
60
+
61
+ // Look up client
62
+ const client = await OAuthClient.find(client_id)
63
+ if (!client || client.revoked) {
64
+ return errorResponse(ctx, new InvalidClientError())
65
+ }
66
+
67
+ // Authenticate client
68
+ if (client.confidential) {
69
+ if (!client_secret) {
70
+ return errorResponse(
71
+ ctx,
72
+ new InvalidClientError('Client secret is required for confidential clients.')
73
+ )
74
+ }
75
+ const valid = await OAuthClient.verifySecret(client, client_secret)
76
+ if (!valid) {
77
+ return errorResponse(ctx, new InvalidClientError())
78
+ }
79
+ }
80
+
81
+ // Consume auth code (validates expiry, redirect_uri, PKCE)
82
+ const codeData = await AuthCode.consume(code, client_id, redirect_uri, code_verifier)
83
+ if (!codeData) {
84
+ return errorResponse(ctx, new InvalidGrantError())
85
+ }
86
+
87
+ // Issue tokens
88
+ const { accessToken, refreshToken, tokenData } = await OAuthToken.create({
89
+ userId: codeData.userId,
90
+ clientId: client_id,
91
+ scopes: codeData.scopes,
92
+ includeRefreshToken: client.grantTypes.includes('refresh_token'),
93
+ })
94
+
95
+ if (Emitter.listenerCount(OAuth2Events.TOKEN_ISSUED) > 0) {
96
+ Emitter.emit(OAuth2Events.TOKEN_ISSUED, {
97
+ ctx,
98
+ userId: codeData.userId,
99
+ clientId: client_id,
100
+ grantType: 'authorization_code',
101
+ }).catch(() => {})
102
+ }
103
+
104
+ return tokenResponse(ctx, accessToken, refreshToken, tokenData.scopes, tokenData.expiresAt)
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Client Credentials Grant
109
+ // ---------------------------------------------------------------------------
110
+
111
+ async function handleClientCredentials(
112
+ ctx: Context,
113
+ body: Record<string, string>
114
+ ): Promise<Response> {
115
+ const { client_id, client_secret, scope } = body
116
+
117
+ if (!client_id || !client_secret) {
118
+ return errorResponse(
119
+ ctx,
120
+ new InvalidRequestError('The client_id and client_secret parameters are required.')
121
+ )
122
+ }
123
+
124
+ const client = await OAuthClient.find(client_id)
125
+ if (!client || client.revoked) {
126
+ return errorResponse(ctx, new InvalidClientError())
127
+ }
128
+
129
+ if (!client.confidential) {
130
+ return errorResponse(
131
+ ctx,
132
+ new InvalidClientError('Client credentials grant requires a confidential client.')
133
+ )
134
+ }
135
+
136
+ if (!client.grantTypes.includes('client_credentials')) {
137
+ return errorResponse(
138
+ ctx,
139
+ new InvalidGrantError('This client does not support the client_credentials grant.')
140
+ )
141
+ }
142
+
143
+ const valid = await OAuthClient.verifySecret(client, client_secret)
144
+ if (!valid) {
145
+ return errorResponse(ctx, new InvalidClientError())
146
+ }
147
+
148
+ // Validate scopes
149
+ const requestedScopes = scope ? scope.split(' ').filter(Boolean) : []
150
+ let scopes: string[]
151
+ try {
152
+ scopes = ScopeRegistry.validate(
153
+ requestedScopes,
154
+ client.scopes,
155
+ OAuth2Manager.config.defaultScopes
156
+ )
157
+ } catch (err) {
158
+ return errorResponse(ctx, err as Error)
159
+ }
160
+
161
+ // Issue access token only (no refresh token, no user)
162
+ const { accessToken, tokenData } = await OAuthToken.create({
163
+ userId: null,
164
+ clientId: client_id,
165
+ scopes,
166
+ includeRefreshToken: false,
167
+ })
168
+
169
+ if (Emitter.listenerCount(OAuth2Events.TOKEN_ISSUED) > 0) {
170
+ Emitter.emit(OAuth2Events.TOKEN_ISSUED, {
171
+ ctx,
172
+ clientId: client_id,
173
+ grantType: 'client_credentials',
174
+ }).catch(() => {})
175
+ }
176
+
177
+ return tokenResponse(ctx, accessToken, null, tokenData.scopes, tokenData.expiresAt)
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Refresh Token Grant
182
+ // ---------------------------------------------------------------------------
183
+
184
+ async function handleRefreshToken(ctx: Context, body: Record<string, string>): Promise<Response> {
185
+ const { refresh_token, client_id, client_secret, scope } = body
186
+
187
+ if (!refresh_token || !client_id) {
188
+ return errorResponse(
189
+ ctx,
190
+ new InvalidRequestError('The refresh_token and client_id parameters are required.')
191
+ )
192
+ }
193
+
194
+ const client = await OAuthClient.find(client_id)
195
+ if (!client || client.revoked) {
196
+ return errorResponse(ctx, new InvalidClientError())
197
+ }
198
+
199
+ // Authenticate confidential clients
200
+ if (client.confidential) {
201
+ if (!client_secret) {
202
+ return errorResponse(
203
+ ctx,
204
+ new InvalidClientError('Client secret is required for confidential clients.')
205
+ )
206
+ }
207
+ const valid = await OAuthClient.verifySecret(client, client_secret)
208
+ if (!valid) {
209
+ return errorResponse(ctx, new InvalidClientError())
210
+ }
211
+ }
212
+
213
+ // Validate refresh token
214
+ const oldToken = await OAuthToken.validateRefreshToken(refresh_token)
215
+ if (!oldToken || oldToken.clientId !== client_id) {
216
+ return errorResponse(ctx, new InvalidGrantError())
217
+ }
218
+
219
+ // Optionally narrow scopes (cannot widen)
220
+ let scopes = oldToken.scopes
221
+ if (scope) {
222
+ const requested = scope.split(' ').filter(Boolean)
223
+ const widened = requested.filter(s => !oldToken.scopes.includes(s))
224
+ if (widened.length > 0) {
225
+ return errorResponse(
226
+ ctx,
227
+ new InvalidRequestError(
228
+ `Cannot widen scopes on refresh. Unknown scopes: ${widened.join(', ')}`
229
+ )
230
+ )
231
+ }
232
+ scopes = requested
233
+ }
234
+
235
+ // Revoke old token (rotation)
236
+ await OAuthToken.revoke(oldToken.id)
237
+
238
+ // Issue new token pair
239
+ const { accessToken, refreshToken, tokenData } = await OAuthToken.create({
240
+ userId: oldToken.userId,
241
+ clientId: client_id,
242
+ scopes,
243
+ includeRefreshToken: true,
244
+ })
245
+
246
+ if (Emitter.listenerCount(OAuth2Events.TOKEN_REFRESHED) > 0) {
247
+ Emitter.emit(OAuth2Events.TOKEN_REFRESHED, {
248
+ ctx,
249
+ userId: oldToken.userId,
250
+ clientId: client_id,
251
+ }).catch(() => {})
252
+ }
253
+
254
+ return tokenResponse(ctx, accessToken, refreshToken, tokenData.scopes, tokenData.expiresAt)
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Helpers
259
+ // ---------------------------------------------------------------------------
260
+
261
+ function tokenResponse(
262
+ ctx: Context,
263
+ accessToken: string,
264
+ refreshToken: string | null,
265
+ scopes: string[],
266
+ expiresAt: Date
267
+ ): Response {
268
+ const expiresIn = Math.floor((expiresAt.getTime() - Date.now()) / 1000)
269
+
270
+ const payload: Record<string, unknown> = {
271
+ access_token: accessToken,
272
+ token_type: 'Bearer',
273
+ expires_in: expiresIn,
274
+ scope: scopes.join(' '),
275
+ }
276
+
277
+ if (refreshToken) {
278
+ payload.refresh_token = refreshToken
279
+ }
280
+
281
+ return ctx.json(payload)
282
+ }
283
+
284
+ function errorResponse(ctx: Context, error: Error): Response {
285
+ if ('toJSON' in error && typeof error.toJSON === 'function') {
286
+ const statusCode = (error as any).statusCode ?? 400
287
+ return ctx.json(error.toJSON(), statusCode)
288
+ }
289
+ return ctx.json({ error: 'server_error', error_description: error.message }, 500)
290
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,98 @@
1
+ import OAuth2Manager from './oauth2_manager.ts'
2
+ import { getUserId } from './utils.ts'
3
+ import OAuthClient from './client.ts'
4
+ import OAuthToken from './token.ts'
5
+ import ScopeRegistry from './scopes.ts'
6
+ import type {
7
+ OAuthClientData,
8
+ OAuthTokenData,
9
+ CreateClientInput,
10
+ ScopeDescription,
11
+ } from './types.ts'
12
+
13
+ /**
14
+ * OAuth2 helper — convenience API for common OAuth2 server operations.
15
+ *
16
+ * @example
17
+ * import { oauth2 } from '@stravigor/oauth2'
18
+ *
19
+ * const { client, plainSecret } = await oauth2.createClient({ name: 'My App', redirectUris: ['...'] })
20
+ * const { token } = await oauth2.createPersonalToken(user, 'CLI Tool', ['read'])
21
+ * await oauth2.revokeToken(tokenId)
22
+ */
23
+ export const oauth2 = {
24
+ /** Create a new OAuth client. Returns the client and plain-text secret (if confidential). */
25
+ async createClient(
26
+ data: CreateClientInput
27
+ ): Promise<{ client: OAuthClientData; plainSecret: string | null }> {
28
+ return OAuthClient.create(data)
29
+ },
30
+
31
+ /** Find a client by ID. */
32
+ async findClient(id: string): Promise<OAuthClientData | null> {
33
+ return OAuthClient.find(id)
34
+ },
35
+
36
+ /** List all non-revoked clients. */
37
+ async listClients(): Promise<OAuthClientData[]> {
38
+ return OAuthClient.all()
39
+ },
40
+
41
+ /** Soft-revoke a client. */
42
+ async revokeClient(id: string): Promise<void> {
43
+ return OAuthClient.revoke(id)
44
+ },
45
+
46
+ /** Issue a personal access token for a user. Token is shown once. */
47
+ async createPersonalToken(
48
+ user: unknown,
49
+ name: string,
50
+ scopes: string[] = []
51
+ ): Promise<{ token: string; tokenData: OAuthTokenData }> {
52
+ const config = OAuth2Manager.config
53
+ if (!config.personalAccessClient) {
54
+ throw new Error(
55
+ 'No personal access client configured. Run "strav oauth2:setup" or set oauth2.personalAccessClient in config.'
56
+ )
57
+ }
58
+
59
+ const userId = getUserId(user)
60
+ const { accessToken, tokenData } = await OAuthToken.create({
61
+ userId,
62
+ clientId: config.personalAccessClient,
63
+ scopes,
64
+ name,
65
+ includeRefreshToken: false,
66
+ accessTokenLifetime: config.personalAccessTokenLifetime,
67
+ })
68
+
69
+ return { token: accessToken, tokenData }
70
+ },
71
+
72
+ /** Revoke a specific token by ID. */
73
+ async revokeToken(tokenId: string): Promise<void> {
74
+ return OAuthToken.revoke(tokenId)
75
+ },
76
+
77
+ /** Revoke all tokens for a user. */
78
+ async revokeAllFor(user: unknown): Promise<void> {
79
+ const userId = getUserId(user)
80
+ return OAuthToken.revokeAllFor(userId)
81
+ },
82
+
83
+ /** Register available scopes. */
84
+ defineScopes(scopes: Record<string, string>): void {
85
+ ScopeRegistry.define(scopes)
86
+ },
87
+
88
+ /** Get descriptions for registered scopes. */
89
+ scopeDescriptions(names?: string[]): ScopeDescription[] {
90
+ if (names) return ScopeRegistry.describe(names)
91
+ return ScopeRegistry.all()
92
+ },
93
+
94
+ /** Validate a plain-text access token and return its data. */
95
+ async validateToken(plainToken: string): Promise<OAuthTokenData | null> {
96
+ return OAuthToken.validate(plainToken)
97
+ },
98
+ }
package/src/index.ts ADDED
@@ -0,0 +1,59 @@
1
+ // Manager & provider
2
+ export { default, default as OAuth2Manager } from './oauth2_manager.ts'
3
+ export { default as OAuth2Provider } from './oauth2_provider.ts'
4
+
5
+ // Helper
6
+ export { oauth2 } from './helpers.ts'
7
+
8
+ // Actions
9
+ export { defineActions } from './actions.ts'
10
+
11
+ // Middleware
12
+ export { oauth } from './middleware/oauth.ts'
13
+ export { scopes } from './middleware/scopes.ts'
14
+
15
+ // Data helpers
16
+ export { default as OAuthClient } from './client.ts'
17
+ export { default as OAuthToken } from './token.ts'
18
+ export { default as AuthCode } from './auth_code.ts'
19
+
20
+ // Scope registry
21
+ export { default as ScopeRegistry } from './scopes.ts'
22
+
23
+ // Handlers (for manual route registration)
24
+ export { authorizeHandler, approveHandler } from './handlers/authorize.ts'
25
+ export { tokenHandler } from './handlers/token.ts'
26
+ export { revokeHandler } from './handlers/revoke.ts'
27
+ export { introspectHandler } from './handlers/introspect.ts'
28
+ export { listClientsHandler, createClientHandler, deleteClientHandler } from './handlers/clients.ts'
29
+ export {
30
+ createPersonalTokenHandler,
31
+ listPersonalTokensHandler,
32
+ revokePersonalTokenHandler,
33
+ } from './handlers/personal_tokens.ts'
34
+
35
+ // Errors
36
+ export {
37
+ OAuth2Error,
38
+ UnsupportedGrantError,
39
+ InvalidClientError,
40
+ InvalidGrantError,
41
+ InvalidRequestError,
42
+ InvalidScopeError,
43
+ AccessDeniedError,
44
+ } from './errors.ts'
45
+
46
+ // Types
47
+ export type {
48
+ GrantType,
49
+ OAuth2Actions,
50
+ OAuth2Config,
51
+ OAuth2Event,
52
+ OAuthClientData,
53
+ OAuthTokenData,
54
+ OAuthAuthCodeData,
55
+ ScopeDescription,
56
+ CreateClientInput,
57
+ RateLimitConfig,
58
+ } from './types.ts'
59
+ export { OAuth2Events } from './types.ts'
@@ -0,0 +1,61 @@
1
+ import type { Middleware } from '@stravigor/core/http/middleware'
2
+ import OAuth2Manager from '../oauth2_manager.ts'
3
+ import OAuthClient from '../client.ts'
4
+ import OAuthToken from '../token.ts'
5
+
6
+ /**
7
+ * OAuth2 Bearer token authentication middleware.
8
+ *
9
+ * Validates the `Authorization: Bearer <token>` header, loads the
10
+ * associated user (if any), and sets `oauth_token` and `oauth_client`
11
+ * on the context state bag.
12
+ *
13
+ * @example
14
+ * import { oauth } from '@stravigor/oauth2'
15
+ *
16
+ * router.group({ prefix: '/api', middleware: [oauth()] }, r => {
17
+ * r.get('/me', (ctx) => ctx.json({ user: ctx.get('user') }))
18
+ * })
19
+ */
20
+ export function oauth(): Middleware {
21
+ return async (ctx, next) => {
22
+ const header = ctx.header('authorization')
23
+ if (!header || !header.startsWith('Bearer ')) {
24
+ return ctx.json(
25
+ { error: 'unauthenticated', error_description: 'Bearer token required.' },
26
+ 401
27
+ )
28
+ }
29
+
30
+ const plain = header.slice(7)
31
+ const tokenData = await OAuthToken.validate(plain)
32
+ if (!tokenData) {
33
+ return ctx.json(
34
+ { error: 'invalid_token', error_description: 'The access token is invalid or expired.' },
35
+ 401
36
+ )
37
+ }
38
+
39
+ // Load user for user-bound tokens (not client_credentials)
40
+ if (tokenData.userId) {
41
+ const user = await OAuth2Manager.actions.findById(tokenData.userId)
42
+ if (!user) {
43
+ return ctx.json(
44
+ { error: 'invalid_token', error_description: 'The token owner no longer exists.' },
45
+ 401
46
+ )
47
+ }
48
+ ctx.set('user', user)
49
+ }
50
+
51
+ // Set token and client on context for downstream use
52
+ ctx.set('oauth_token', tokenData)
53
+
54
+ const client = await OAuthClient.find(tokenData.clientId)
55
+ if (client) {
56
+ ctx.set('oauth_client', client)
57
+ }
58
+
59
+ return next()
60
+ }
61
+ }
@@ -0,0 +1,37 @@
1
+ import type { Middleware } from '@stravigor/core/http/middleware'
2
+ import type { OAuthTokenData } from '../types.ts'
3
+
4
+ /**
5
+ * Scope enforcement middleware.
6
+ *
7
+ * Checks that the current OAuth token has all the required scopes.
8
+ * Must be used after `oauth()` middleware.
9
+ *
10
+ * @example
11
+ * import { oauth, scopes } from '@stravigor/oauth2'
12
+ * import { compose } from '@stravigor/core/http/middleware'
13
+ *
14
+ * r.get('/repos', compose([oauth(), scopes('repos:read')], handler))
15
+ * r.post('/repos', compose([oauth(), scopes('repos:read', 'repos:write')], handler))
16
+ */
17
+ export function scopes(...required: string[]): Middleware {
18
+ return (ctx, next) => {
19
+ const token = ctx.get<OAuthTokenData>('oauth_token')
20
+ if (!token) {
21
+ return ctx.json({ error: 'unauthenticated', error_description: 'OAuth token required.' }, 401)
22
+ }
23
+
24
+ const missing = required.filter(s => !token.scopes.includes(s))
25
+ if (missing.length > 0) {
26
+ return ctx.json(
27
+ {
28
+ error: 'insufficient_scope',
29
+ error_description: `Missing required scopes: ${missing.join(', ')}`,
30
+ },
31
+ 403
32
+ )
33
+ }
34
+
35
+ return next()
36
+ }
37
+ }