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