@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,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
|
+
}
|