@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,243 @@
|
|
|
1
|
+
import type Context from '@stravigor/core/http/context'
|
|
2
|
+
import type Session from '@stravigor/core/session/session'
|
|
3
|
+
import Emitter from '@stravigor/core/events/emitter'
|
|
4
|
+
import { getUserId } from '../utils.ts'
|
|
5
|
+
import OAuth2Manager from '../oauth2_manager.ts'
|
|
6
|
+
import OAuthClient from '../client.ts'
|
|
7
|
+
import AuthCode from '../auth_code.ts'
|
|
8
|
+
import ScopeRegistry from '../scopes.ts'
|
|
9
|
+
import { OAuth2Events } from '../types.ts'
|
|
10
|
+
import {
|
|
11
|
+
InvalidRequestError,
|
|
12
|
+
InvalidClientError,
|
|
13
|
+
InvalidScopeError,
|
|
14
|
+
AccessDeniedError,
|
|
15
|
+
} from '../errors.ts'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* GET /oauth/authorize
|
|
19
|
+
*
|
|
20
|
+
* Initiates the authorization code flow. Validates the request parameters,
|
|
21
|
+
* then either auto-approves (first-party client) or shows the consent screen.
|
|
22
|
+
*/
|
|
23
|
+
export async function authorizeHandler(ctx: Context): Promise<Response> {
|
|
24
|
+
const responseType = ctx.qs('response_type')
|
|
25
|
+
const clientId = ctx.qs('client_id')
|
|
26
|
+
const redirectUri = ctx.qs('redirect_uri')
|
|
27
|
+
const scopeParam = ctx.qs('scope')
|
|
28
|
+
const state = ctx.qs('state')
|
|
29
|
+
const codeChallenge = ctx.qs('code_challenge')
|
|
30
|
+
const codeChallengeMethod = ctx.qs('code_challenge_method') ?? 'plain'
|
|
31
|
+
|
|
32
|
+
// Validate required params
|
|
33
|
+
if (responseType !== 'code') {
|
|
34
|
+
return ctx.json(new InvalidRequestError('The response_type must be "code".').toJSON(), 400)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!clientId) {
|
|
38
|
+
return ctx.json(new InvalidRequestError('The client_id parameter is required.').toJSON(), 400)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Look up client
|
|
42
|
+
const client = await OAuthClient.find(clientId)
|
|
43
|
+
if (!client || client.revoked) {
|
|
44
|
+
return ctx.json(new InvalidClientError().toJSON(), 401)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Must support authorization_code grant
|
|
48
|
+
if (!client.grantTypes.includes('authorization_code')) {
|
|
49
|
+
return ctx.json(
|
|
50
|
+
new InvalidRequestError(
|
|
51
|
+
'This client does not support the authorization_code grant.'
|
|
52
|
+
).toJSON(),
|
|
53
|
+
400
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Validate redirect URI
|
|
58
|
+
if (!redirectUri || !client.redirectUris.includes(redirectUri)) {
|
|
59
|
+
return ctx.json(
|
|
60
|
+
new InvalidRequestError(
|
|
61
|
+
'The redirect_uri is missing or not registered for this client.'
|
|
62
|
+
).toJSON(),
|
|
63
|
+
400
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Public clients must use PKCE
|
|
68
|
+
if (!client.confidential && !codeChallenge) {
|
|
69
|
+
return errorRedirect(
|
|
70
|
+
redirectUri,
|
|
71
|
+
state,
|
|
72
|
+
'invalid_request',
|
|
73
|
+
'Public clients must use PKCE (code_challenge required).'
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Validate code_challenge_method
|
|
78
|
+
if (codeChallenge && codeChallengeMethod !== 'S256' && codeChallengeMethod !== 'plain') {
|
|
79
|
+
return errorRedirect(
|
|
80
|
+
redirectUri,
|
|
81
|
+
state,
|
|
82
|
+
'invalid_request',
|
|
83
|
+
'Unsupported code_challenge_method. Use "S256" or "plain".'
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Validate scopes
|
|
88
|
+
const requestedScopes = scopeParam ? scopeParam.split(' ').filter(Boolean) : []
|
|
89
|
+
let scopes: string[]
|
|
90
|
+
try {
|
|
91
|
+
scopes = ScopeRegistry.validate(
|
|
92
|
+
requestedScopes,
|
|
93
|
+
client.scopes,
|
|
94
|
+
OAuth2Manager.config.defaultScopes
|
|
95
|
+
)
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (err instanceof InvalidScopeError) {
|
|
98
|
+
return errorRedirect(redirectUri, state, 'invalid_scope', err.message)
|
|
99
|
+
}
|
|
100
|
+
throw err
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Store authorization request in session for the POST approval step
|
|
104
|
+
const session = ctx.get<Session>('session')
|
|
105
|
+
session.set('_oauth2_auth_request', {
|
|
106
|
+
clientId,
|
|
107
|
+
redirectUri,
|
|
108
|
+
scopes,
|
|
109
|
+
state: state ?? null,
|
|
110
|
+
codeChallenge: codeChallenge ?? null,
|
|
111
|
+
codeChallengeMethod: codeChallenge ? codeChallengeMethod : null,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// First-party clients skip consent
|
|
115
|
+
if (client.firstParty) {
|
|
116
|
+
return issueAuthorizationCode(
|
|
117
|
+
ctx,
|
|
118
|
+
client.id,
|
|
119
|
+
redirectUri,
|
|
120
|
+
scopes,
|
|
121
|
+
state,
|
|
122
|
+
codeChallenge,
|
|
123
|
+
codeChallengeMethod
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Third-party: render consent screen
|
|
128
|
+
const scopeDescriptions = ScopeRegistry.describe(scopes)
|
|
129
|
+
if (OAuth2Manager.actions.renderAuthorization) {
|
|
130
|
+
return OAuth2Manager.actions.renderAuthorization(ctx, client, scopeDescriptions)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Default: JSON response for SPA-based consent
|
|
134
|
+
return ctx.json({
|
|
135
|
+
authorization_required: true,
|
|
136
|
+
client: { id: client.id, name: client.name },
|
|
137
|
+
scopes: scopeDescriptions,
|
|
138
|
+
state: state ?? null,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* POST /oauth/authorize
|
|
144
|
+
*
|
|
145
|
+
* Handles the user's approval or denial of the authorization request.
|
|
146
|
+
*/
|
|
147
|
+
export async function approveHandler(ctx: Context): Promise<Response> {
|
|
148
|
+
const body = await ctx.body<{ approved?: boolean }>()
|
|
149
|
+
const session = ctx.get<Session>('session')
|
|
150
|
+
|
|
151
|
+
const authRequest = session.get<{
|
|
152
|
+
clientId: string
|
|
153
|
+
redirectUri: string
|
|
154
|
+
scopes: string[]
|
|
155
|
+
state: string | null
|
|
156
|
+
codeChallenge: string | null
|
|
157
|
+
codeChallengeMethod: string | null
|
|
158
|
+
}>('_oauth2_auth_request')
|
|
159
|
+
|
|
160
|
+
if (!authRequest) {
|
|
161
|
+
return ctx.json(new InvalidRequestError('No pending authorization request.').toJSON(), 400)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Clear the session data
|
|
165
|
+
session.forget('_oauth2_auth_request')
|
|
166
|
+
|
|
167
|
+
// User denied
|
|
168
|
+
if (!body.approved) {
|
|
169
|
+
if (Emitter.listenerCount(OAuth2Events.ACCESS_DENIED) > 0) {
|
|
170
|
+
Emitter.emit(OAuth2Events.ACCESS_DENIED, { ctx, clientId: authRequest.clientId }).catch(
|
|
171
|
+
() => {}
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
return errorRedirect(
|
|
175
|
+
authRequest.redirectUri,
|
|
176
|
+
authRequest.state,
|
|
177
|
+
'access_denied',
|
|
178
|
+
'The resource owner denied the request.'
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// User approved — issue code
|
|
183
|
+
return issueAuthorizationCode(
|
|
184
|
+
ctx,
|
|
185
|
+
authRequest.clientId,
|
|
186
|
+
authRequest.redirectUri,
|
|
187
|
+
authRequest.scopes,
|
|
188
|
+
authRequest.state,
|
|
189
|
+
authRequest.codeChallenge,
|
|
190
|
+
authRequest.codeChallengeMethod
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Internal
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
async function issueAuthorizationCode(
|
|
199
|
+
ctx: Context,
|
|
200
|
+
clientId: string,
|
|
201
|
+
redirectUri: string,
|
|
202
|
+
scopes: string[],
|
|
203
|
+
state: string | null,
|
|
204
|
+
codeChallenge: string | null,
|
|
205
|
+
codeChallengeMethod: string | null
|
|
206
|
+
): Promise<Response> {
|
|
207
|
+
const user = ctx.get('user')
|
|
208
|
+
const userId = getUserId(user)
|
|
209
|
+
|
|
210
|
+
const { code } = await AuthCode.create({
|
|
211
|
+
clientId,
|
|
212
|
+
userId,
|
|
213
|
+
redirectUri,
|
|
214
|
+
scopes,
|
|
215
|
+
codeChallenge,
|
|
216
|
+
codeChallengeMethod,
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
if (Emitter.listenerCount(OAuth2Events.CODE_ISSUED) > 0) {
|
|
220
|
+
Emitter.emit(OAuth2Events.CODE_ISSUED, { ctx, clientId, userId }).catch(() => {})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Redirect back to client with code
|
|
224
|
+
const url = new URL(redirectUri)
|
|
225
|
+
url.searchParams.set('code', code)
|
|
226
|
+
if (state) url.searchParams.set('state', state)
|
|
227
|
+
|
|
228
|
+
return ctx.redirect(url.toString())
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function errorRedirect(
|
|
232
|
+
redirectUri: string,
|
|
233
|
+
state: string | null,
|
|
234
|
+
error: string,
|
|
235
|
+
description: string
|
|
236
|
+
): Response {
|
|
237
|
+
const url = new URL(redirectUri)
|
|
238
|
+
url.searchParams.set('error', error)
|
|
239
|
+
url.searchParams.set('error_description', description)
|
|
240
|
+
if (state) url.searchParams.set('state', state)
|
|
241
|
+
|
|
242
|
+
return Response.redirect(url.toString(), 302)
|
|
243
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type Context from '@stravigor/core/http/context'
|
|
2
|
+
import Emitter from '@stravigor/core/events/emitter'
|
|
3
|
+
import OAuthClient from '../client.ts'
|
|
4
|
+
import { OAuth2Events } from '../types.ts'
|
|
5
|
+
import type { GrantType } from '../types.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* GET /oauth/clients
|
|
9
|
+
*
|
|
10
|
+
* List all non-revoked OAuth clients.
|
|
11
|
+
*/
|
|
12
|
+
export async function listClientsHandler(ctx: Context): Promise<Response> {
|
|
13
|
+
const clients = await OAuthClient.all()
|
|
14
|
+
|
|
15
|
+
return ctx.json({
|
|
16
|
+
clients: clients.map(c => ({
|
|
17
|
+
id: c.id,
|
|
18
|
+
name: c.name,
|
|
19
|
+
redirect_uris: c.redirectUris,
|
|
20
|
+
grant_types: c.grantTypes,
|
|
21
|
+
confidential: c.confidential,
|
|
22
|
+
first_party: c.firstParty,
|
|
23
|
+
created_at: c.createdAt,
|
|
24
|
+
})),
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* POST /oauth/clients
|
|
30
|
+
*
|
|
31
|
+
* Create a new OAuth client. Returns the client record and plain secret.
|
|
32
|
+
*/
|
|
33
|
+
export async function createClientHandler(ctx: Context): Promise<Response> {
|
|
34
|
+
const body = await ctx.body<{
|
|
35
|
+
name?: string
|
|
36
|
+
redirect_uris?: string[]
|
|
37
|
+
confidential?: boolean
|
|
38
|
+
first_party?: boolean
|
|
39
|
+
scopes?: string[] | null
|
|
40
|
+
grant_types?: GrantType[]
|
|
41
|
+
}>()
|
|
42
|
+
|
|
43
|
+
if (!body.name) {
|
|
44
|
+
return ctx.json({ message: 'The name field is required.' }, 422)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!body.redirect_uris || body.redirect_uris.length === 0) {
|
|
48
|
+
return ctx.json({ message: 'At least one redirect_uri is required.' }, 422)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const { client, plainSecret } = await OAuthClient.create({
|
|
52
|
+
name: body.name,
|
|
53
|
+
redirectUris: body.redirect_uris,
|
|
54
|
+
confidential: body.confidential,
|
|
55
|
+
firstParty: body.first_party,
|
|
56
|
+
scopes: body.scopes,
|
|
57
|
+
grantTypes: body.grant_types,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
if (Emitter.listenerCount(OAuth2Events.CLIENT_CREATED) > 0) {
|
|
61
|
+
Emitter.emit(OAuth2Events.CLIENT_CREATED, { ctx, client }).catch(() => {})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return ctx.json(
|
|
65
|
+
{
|
|
66
|
+
client: {
|
|
67
|
+
id: client.id,
|
|
68
|
+
name: client.name,
|
|
69
|
+
redirect_uris: client.redirectUris,
|
|
70
|
+
grant_types: client.grantTypes,
|
|
71
|
+
confidential: client.confidential,
|
|
72
|
+
first_party: client.firstParty,
|
|
73
|
+
created_at: client.createdAt,
|
|
74
|
+
},
|
|
75
|
+
secret: plainSecret,
|
|
76
|
+
},
|
|
77
|
+
201
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* DELETE /oauth/clients/:id
|
|
83
|
+
*
|
|
84
|
+
* Soft-revoke an OAuth client and all its tokens.
|
|
85
|
+
*/
|
|
86
|
+
export async function deleteClientHandler(ctx: Context): Promise<Response> {
|
|
87
|
+
const id = ctx.params.id!
|
|
88
|
+
|
|
89
|
+
const client = await OAuthClient.findIncludingRevoked(id)
|
|
90
|
+
if (!client) {
|
|
91
|
+
return ctx.json({ message: 'Client not found.' }, 404)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await OAuthClient.revoke(id)
|
|
95
|
+
|
|
96
|
+
if (Emitter.listenerCount(OAuth2Events.CLIENT_REVOKED) > 0) {
|
|
97
|
+
Emitter.emit(OAuth2Events.CLIENT_REVOKED, { ctx, clientId: id }).catch(() => {})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return ctx.json({ message: 'Client revoked.' })
|
|
101
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type Context from '@stravigor/core/http/context'
|
|
2
|
+
import OAuthClient from '../client.ts'
|
|
3
|
+
import OAuthToken from '../token.ts'
|
|
4
|
+
import { InvalidClientError } from '../errors.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* POST /oauth/introspect (RFC 7662)
|
|
8
|
+
*
|
|
9
|
+
* Returns metadata about a token. Used by resource servers to validate
|
|
10
|
+
* tokens without needing direct database access.
|
|
11
|
+
*/
|
|
12
|
+
export async function introspectHandler(ctx: Context): Promise<Response> {
|
|
13
|
+
const body = await ctx.body<{
|
|
14
|
+
token?: string
|
|
15
|
+
token_type_hint?: string
|
|
16
|
+
client_id?: string
|
|
17
|
+
client_secret?: string
|
|
18
|
+
}>()
|
|
19
|
+
|
|
20
|
+
const { token, client_id, client_secret } = body
|
|
21
|
+
|
|
22
|
+
if (!token) {
|
|
23
|
+
return ctx.json(
|
|
24
|
+
{ error: 'invalid_request', error_description: 'The token parameter is required.' },
|
|
25
|
+
400
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Authenticate the requesting client
|
|
30
|
+
if (client_id) {
|
|
31
|
+
const client = await OAuthClient.find(client_id)
|
|
32
|
+
if (!client || client.revoked) {
|
|
33
|
+
return ctx.json(new InvalidClientError().toJSON(), 401)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (client.confidential && client_secret) {
|
|
37
|
+
const valid = await OAuthClient.verifySecret(client, client_secret)
|
|
38
|
+
if (!valid) {
|
|
39
|
+
return ctx.json(new InvalidClientError().toJSON(), 401)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Validate the token
|
|
45
|
+
const tokenData = await OAuthToken.validate(token)
|
|
46
|
+
if (!tokenData) {
|
|
47
|
+
// Inactive token — return minimal response per RFC 7662
|
|
48
|
+
return ctx.json({ active: false })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Active token — return metadata
|
|
52
|
+
return ctx.json({
|
|
53
|
+
active: true,
|
|
54
|
+
scope: tokenData.scopes.join(' '),
|
|
55
|
+
client_id: tokenData.clientId,
|
|
56
|
+
token_type: 'Bearer',
|
|
57
|
+
exp: Math.floor(tokenData.expiresAt.getTime() / 1000),
|
|
58
|
+
iat: Math.floor(tokenData.createdAt.getTime() / 1000),
|
|
59
|
+
sub: tokenData.userId ?? undefined,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type Context from '@stravigor/core/http/context'
|
|
2
|
+
import Emitter from '@stravigor/core/events/emitter'
|
|
3
|
+
import { getUserId } from '../utils.ts'
|
|
4
|
+
import OAuth2Manager from '../oauth2_manager.ts'
|
|
5
|
+
import OAuthToken from '../token.ts'
|
|
6
|
+
import ScopeRegistry from '../scopes.ts'
|
|
7
|
+
import { OAuth2Events } from '../types.ts'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* POST /oauth/personal-tokens
|
|
11
|
+
*
|
|
12
|
+
* Issue a personal access token for the authenticated user.
|
|
13
|
+
* Returns the plain-text token (shown once).
|
|
14
|
+
*/
|
|
15
|
+
export async function createPersonalTokenHandler(ctx: Context): Promise<Response> {
|
|
16
|
+
const config = OAuth2Manager.config
|
|
17
|
+
|
|
18
|
+
if (!config.personalAccessClient) {
|
|
19
|
+
return ctx.json(
|
|
20
|
+
{ message: 'No personal access client configured. Run "strav oauth2:setup" first.' },
|
|
21
|
+
500
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const body = await ctx.body<{ name?: string; scopes?: string[] }>()
|
|
26
|
+
|
|
27
|
+
if (!body.name) {
|
|
28
|
+
return ctx.json({ message: 'The name field is required.' }, 422)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Validate scopes if provided
|
|
32
|
+
const scopes = body.scopes ?? []
|
|
33
|
+
if (scopes.length > 0) {
|
|
34
|
+
for (const scope of scopes) {
|
|
35
|
+
if (!ScopeRegistry.has(scope)) {
|
|
36
|
+
return ctx.json({ message: `Unknown scope: "${scope}".` }, 422)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const user = ctx.get('user')
|
|
42
|
+
const userId = getUserId(user)
|
|
43
|
+
|
|
44
|
+
const { accessToken, tokenData } = await OAuthToken.create({
|
|
45
|
+
userId,
|
|
46
|
+
clientId: config.personalAccessClient,
|
|
47
|
+
scopes,
|
|
48
|
+
name: body.name,
|
|
49
|
+
includeRefreshToken: false,
|
|
50
|
+
accessTokenLifetime: config.personalAccessTokenLifetime,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
if (Emitter.listenerCount(OAuth2Events.TOKEN_ISSUED) > 0) {
|
|
54
|
+
Emitter.emit(OAuth2Events.TOKEN_ISSUED, {
|
|
55
|
+
ctx,
|
|
56
|
+
userId,
|
|
57
|
+
clientId: config.personalAccessClient,
|
|
58
|
+
grantType: 'personal_access_token',
|
|
59
|
+
}).catch(() => {})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return ctx.json(
|
|
63
|
+
{
|
|
64
|
+
token: accessToken,
|
|
65
|
+
accessToken: {
|
|
66
|
+
id: tokenData.id,
|
|
67
|
+
name: tokenData.name,
|
|
68
|
+
scopes: tokenData.scopes,
|
|
69
|
+
expires_at: tokenData.expiresAt,
|
|
70
|
+
created_at: tokenData.createdAt,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
201
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* GET /oauth/personal-tokens
|
|
79
|
+
*
|
|
80
|
+
* List all active personal access tokens for the authenticated user.
|
|
81
|
+
*/
|
|
82
|
+
export async function listPersonalTokensHandler(ctx: Context): Promise<Response> {
|
|
83
|
+
const user = ctx.get('user')
|
|
84
|
+
const userId = getUserId(user)
|
|
85
|
+
|
|
86
|
+
const tokens = await OAuthToken.personalTokensFor(userId)
|
|
87
|
+
|
|
88
|
+
return ctx.json({
|
|
89
|
+
tokens: tokens.map(t => ({
|
|
90
|
+
id: t.id,
|
|
91
|
+
name: t.name,
|
|
92
|
+
scopes: t.scopes,
|
|
93
|
+
last_used_at: t.lastUsedAt,
|
|
94
|
+
expires_at: t.expiresAt,
|
|
95
|
+
created_at: t.createdAt,
|
|
96
|
+
})),
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* DELETE /oauth/personal-tokens/:id
|
|
102
|
+
*
|
|
103
|
+
* Revoke a specific personal access token.
|
|
104
|
+
*/
|
|
105
|
+
export async function revokePersonalTokenHandler(ctx: Context): Promise<Response> {
|
|
106
|
+
const tokenId = ctx.params.id!
|
|
107
|
+
|
|
108
|
+
await OAuthToken.revoke(tokenId)
|
|
109
|
+
|
|
110
|
+
if (Emitter.listenerCount(OAuth2Events.TOKEN_REVOKED) > 0) {
|
|
111
|
+
Emitter.emit(OAuth2Events.TOKEN_REVOKED, { ctx, tokenId }).catch(() => {})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return ctx.json({ message: 'Token revoked.' })
|
|
115
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type Context from '@stravigor/core/http/context'
|
|
2
|
+
import Emitter from '@stravigor/core/events/emitter'
|
|
3
|
+
import OAuthClient from '../client.ts'
|
|
4
|
+
import OAuthToken from '../token.ts'
|
|
5
|
+
import { OAuth2Events } from '../types.ts'
|
|
6
|
+
import { InvalidClientError } from '../errors.ts'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* POST /oauth/revoke (RFC 7009)
|
|
10
|
+
*
|
|
11
|
+
* Revokes an access token or refresh token.
|
|
12
|
+
* Always returns 200 regardless of whether the token existed
|
|
13
|
+
* (to prevent information leakage).
|
|
14
|
+
*/
|
|
15
|
+
export async function revokeHandler(ctx: Context): Promise<Response> {
|
|
16
|
+
const body = await ctx.body<{
|
|
17
|
+
token?: string
|
|
18
|
+
token_type_hint?: string
|
|
19
|
+
client_id?: string
|
|
20
|
+
client_secret?: string
|
|
21
|
+
}>()
|
|
22
|
+
|
|
23
|
+
const { token, client_id, client_secret } = body
|
|
24
|
+
|
|
25
|
+
if (!token) {
|
|
26
|
+
return ctx.json(
|
|
27
|
+
{ error: 'invalid_request', error_description: 'The token parameter is required.' },
|
|
28
|
+
400
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Authenticate the client if credentials are provided
|
|
33
|
+
if (client_id) {
|
|
34
|
+
const client = await OAuthClient.find(client_id)
|
|
35
|
+
if (!client || client.revoked) {
|
|
36
|
+
return ctx.json(new InvalidClientError().toJSON(), 401)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (client.confidential && client_secret) {
|
|
40
|
+
const valid = await OAuthClient.verifySecret(client, client_secret)
|
|
41
|
+
if (!valid) {
|
|
42
|
+
return ctx.json(new InvalidClientError().toJSON(), 401)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Try revoking as access token first, then as refresh token
|
|
48
|
+
const tokenData = await OAuthToken.validate(token)
|
|
49
|
+
if (tokenData) {
|
|
50
|
+
await OAuthToken.revoke(tokenData.id)
|
|
51
|
+
|
|
52
|
+
if (Emitter.listenerCount(OAuth2Events.TOKEN_REVOKED) > 0) {
|
|
53
|
+
Emitter.emit(OAuth2Events.TOKEN_REVOKED, { ctx, tokenId: tokenData.id }).catch(() => {})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return ctx.json({})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Try as refresh token
|
|
60
|
+
const refreshData = await OAuthToken.validateRefreshToken(token)
|
|
61
|
+
if (refreshData) {
|
|
62
|
+
await OAuthToken.revoke(refreshData.id)
|
|
63
|
+
|
|
64
|
+
if (Emitter.listenerCount(OAuth2Events.TOKEN_REVOKED) > 0) {
|
|
65
|
+
Emitter.emit(OAuth2Events.TOKEN_REVOKED, { ctx, tokenId: refreshData.id }).catch(() => {})
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// RFC 7009: Always respond with 200 even if token not found
|
|
70
|
+
return ctx.json({})
|
|
71
|
+
}
|