@mantiq/oauth 0.1.0
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/package.json +41 -0
- package/src/OAuthServer.ts +75 -0
- package/src/OAuthServiceProvider.ts +89 -0
- package/src/commands/OAuthClientCommand.ts +50 -0
- package/src/commands/OAuthInstallCommand.ts +67 -0
- package/src/commands/OAuthKeysCommand.ts +43 -0
- package/src/commands/OAuthPurgeCommand.ts +89 -0
- package/src/errors/OAuthError.ts +22 -0
- package/src/grants/AuthCodeGrant.ts +159 -0
- package/src/grants/ClientCredentialsGrant.ts +79 -0
- package/src/grants/GrantHandler.ts +14 -0
- package/src/grants/PersonalAccessGrant.ts +72 -0
- package/src/grants/RefreshTokenGrant.ts +129 -0
- package/src/guards/JwtGuard.ts +95 -0
- package/src/helpers/oauth.ts +15 -0
- package/src/index.ts +58 -0
- package/src/jwt/JwtEncoder.ts +51 -0
- package/src/jwt/JwtPayload.ts +9 -0
- package/src/jwt/JwtSigner.ts +175 -0
- package/src/middleware/CheckClientCredentials.ts +60 -0
- package/src/middleware/CheckForAnyScope.ts +52 -0
- package/src/middleware/CheckScopes.ts +53 -0
- package/src/migrations/CreateOAuthAccessTokensTable.ts +21 -0
- package/src/migrations/CreateOAuthAuthCodesTable.ts +22 -0
- package/src/migrations/CreateOAuthClientsTable.ts +24 -0
- package/src/migrations/CreateOAuthRefreshTokensTable.ts +18 -0
- package/src/models/AccessToken.ts +52 -0
- package/src/models/AuthCode.ts +20 -0
- package/src/models/Client.ts +36 -0
- package/src/models/RefreshToken.ts +23 -0
- package/src/routes/AuthorizationController.ts +132 -0
- package/src/routes/ClientController.ts +138 -0
- package/src/routes/ScopeController.ts +19 -0
- package/src/routes/TokenController.ts +38 -0
- package/src/routes/oauthRoutes.ts +55 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { MantiqRequest } from '@mantiq/core'
|
|
2
|
+
import type { OAuthServer } from '../OAuthServer.ts'
|
|
3
|
+
import { Client } from '../models/Client.ts'
|
|
4
|
+
import { AuthCode } from '../models/AuthCode.ts'
|
|
5
|
+
import { OAuthError } from '../errors/OAuthError.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Handles the authorization code flow endpoints:
|
|
9
|
+
* - GET /oauth/authorize — show authorization form / validate params
|
|
10
|
+
* - POST /oauth/authorize — approve the authorization request
|
|
11
|
+
* - DELETE /oauth/authorize — deny the authorization request
|
|
12
|
+
*/
|
|
13
|
+
export class AuthorizationController {
|
|
14
|
+
constructor(private readonly server: OAuthServer) {}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* GET /oauth/authorize
|
|
18
|
+
* Validates the authorization request parameters and returns client info + requested scopes.
|
|
19
|
+
*/
|
|
20
|
+
async authorize(request: MantiqRequest): Promise<Response> {
|
|
21
|
+
const clientId = request.query('client_id')
|
|
22
|
+
const redirectUri = request.query('redirect_uri')
|
|
23
|
+
const responseType = request.query('response_type')
|
|
24
|
+
const scopeParam = request.query('scope')
|
|
25
|
+
const state = request.query('state')
|
|
26
|
+
|
|
27
|
+
if (!clientId) throw new OAuthError('The client_id parameter is required.', 'invalid_request')
|
|
28
|
+
if (!redirectUri) throw new OAuthError('The redirect_uri parameter is required.', 'invalid_request')
|
|
29
|
+
if (responseType !== 'code') throw new OAuthError('Only response_type=code is supported.', 'unsupported_response_type')
|
|
30
|
+
|
|
31
|
+
const client = await Client.find(clientId)
|
|
32
|
+
if (!client) throw new OAuthError('Client not found.', 'invalid_client')
|
|
33
|
+
|
|
34
|
+
// Validate redirect URI
|
|
35
|
+
const allowedRedirect = client.getAttribute('redirect') as string
|
|
36
|
+
if (allowedRedirect && redirectUri !== allowedRedirect) {
|
|
37
|
+
throw new OAuthError('Invalid redirect URI.', 'invalid_request')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const scopes = scopeParam ? scopeParam.split(' ').filter(Boolean) : []
|
|
41
|
+
const scopeDetails = scopes.map((s) => ({
|
|
42
|
+
id: s,
|
|
43
|
+
description: this.server.scopes().find((sc) => sc.id === s)?.description ?? s,
|
|
44
|
+
}))
|
|
45
|
+
|
|
46
|
+
return new Response(JSON.stringify({
|
|
47
|
+
client: {
|
|
48
|
+
id: client.getKey(),
|
|
49
|
+
name: client.getAttribute('name'),
|
|
50
|
+
},
|
|
51
|
+
scopes: scopeDetails,
|
|
52
|
+
state,
|
|
53
|
+
redirect_uri: redirectUri,
|
|
54
|
+
}), {
|
|
55
|
+
status: 200,
|
|
56
|
+
headers: { 'Content-Type': 'application/json' },
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* POST /oauth/authorize
|
|
62
|
+
* Approve the authorization request and issue an authorization code.
|
|
63
|
+
*/
|
|
64
|
+
async approve(request: MantiqRequest): Promise<Response> {
|
|
65
|
+
const user = request.user<any>()
|
|
66
|
+
if (!user) throw new OAuthError('User must be authenticated.', 'invalid_request', 401)
|
|
67
|
+
|
|
68
|
+
const clientId = await request.input('client_id') as string | undefined
|
|
69
|
+
const redirectUri = await request.input('redirect_uri') as string | undefined
|
|
70
|
+
const scopeParam = await request.input('scope') as string | undefined
|
|
71
|
+
const state = await request.input('state') as string | undefined
|
|
72
|
+
const codeChallenge = await request.input('code_challenge') as string | undefined
|
|
73
|
+
const codeChallengeMethod = await request.input('code_challenge_method') as string | undefined
|
|
74
|
+
|
|
75
|
+
if (!clientId) throw new OAuthError('The client_id parameter is required.', 'invalid_request')
|
|
76
|
+
if (!redirectUri) throw new OAuthError('The redirect_uri parameter is required.', 'invalid_request')
|
|
77
|
+
|
|
78
|
+
const client = await Client.find(clientId)
|
|
79
|
+
if (!client) throw new OAuthError('Client not found.', 'invalid_client')
|
|
80
|
+
|
|
81
|
+
const userId = typeof user.getAuthIdentifier === 'function'
|
|
82
|
+
? user.getAuthIdentifier()
|
|
83
|
+
: user.id ?? user.getAttribute?.('id')
|
|
84
|
+
|
|
85
|
+
const scopes = scopeParam ? scopeParam.split(' ').filter(Boolean) : []
|
|
86
|
+
const codeId = crypto.randomUUID()
|
|
87
|
+
const now = new Date()
|
|
88
|
+
const expiresAt = new Date(now.getTime() + 10 * 60 * 1000) // 10 minutes
|
|
89
|
+
|
|
90
|
+
await AuthCode.create({
|
|
91
|
+
id: codeId,
|
|
92
|
+
user_id: String(userId),
|
|
93
|
+
client_id: clientId,
|
|
94
|
+
scopes: JSON.stringify(scopes),
|
|
95
|
+
revoked: false,
|
|
96
|
+
expires_at: expiresAt.toISOString(),
|
|
97
|
+
code_challenge: codeChallenge ?? null,
|
|
98
|
+
code_challenge_method: codeChallengeMethod ?? null,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// Build redirect URL with code
|
|
102
|
+
const url = new URL(redirectUri)
|
|
103
|
+
url.searchParams.set('code', codeId)
|
|
104
|
+
if (state) url.searchParams.set('state', state)
|
|
105
|
+
|
|
106
|
+
return new Response(null, {
|
|
107
|
+
status: 302,
|
|
108
|
+
headers: { 'Location': url.toString() },
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* DELETE /oauth/authorize
|
|
114
|
+
* Deny the authorization request.
|
|
115
|
+
*/
|
|
116
|
+
async deny(request: MantiqRequest): Promise<Response> {
|
|
117
|
+
const redirectUri = await request.input('redirect_uri') as string | undefined
|
|
118
|
+
const state = await request.input('state') as string | undefined
|
|
119
|
+
|
|
120
|
+
if (!redirectUri) throw new OAuthError('The redirect_uri parameter is required.', 'invalid_request')
|
|
121
|
+
|
|
122
|
+
const url = new URL(redirectUri)
|
|
123
|
+
url.searchParams.set('error', 'access_denied')
|
|
124
|
+
url.searchParams.set('error_description', 'The user denied the authorization request.')
|
|
125
|
+
if (state) url.searchParams.set('state', state)
|
|
126
|
+
|
|
127
|
+
return new Response(null, {
|
|
128
|
+
status: 302,
|
|
129
|
+
headers: { 'Location': url.toString() },
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { MantiqRequest } from '@mantiq/core'
|
|
2
|
+
import { Client } from '../models/Client.ts'
|
|
3
|
+
import { OAuthError } from '../errors/OAuthError.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CRUD controller for OAuth clients.
|
|
7
|
+
* All endpoints require authentication.
|
|
8
|
+
*/
|
|
9
|
+
export class ClientController {
|
|
10
|
+
/**
|
|
11
|
+
* GET /oauth/clients
|
|
12
|
+
* List all clients for the authenticated user.
|
|
13
|
+
*/
|
|
14
|
+
async index(request: MantiqRequest): Promise<Response> {
|
|
15
|
+
const user = request.user<any>()
|
|
16
|
+
if (!user) throw new OAuthError('Unauthenticated.', 'invalid_request', 401)
|
|
17
|
+
|
|
18
|
+
const userId = typeof user.getAuthIdentifier === 'function'
|
|
19
|
+
? user.getAuthIdentifier()
|
|
20
|
+
: user.id ?? user.getAttribute?.('id')
|
|
21
|
+
|
|
22
|
+
const clients = await Client.where('user_id', userId).get()
|
|
23
|
+
const data = clients.map((c) => c.toJSON())
|
|
24
|
+
|
|
25
|
+
return new Response(JSON.stringify(data), {
|
|
26
|
+
status: 200,
|
|
27
|
+
headers: { 'Content-Type': 'application/json' },
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* POST /oauth/clients
|
|
33
|
+
* Create a new OAuth client.
|
|
34
|
+
*/
|
|
35
|
+
async store(request: MantiqRequest): Promise<Response> {
|
|
36
|
+
const user = request.user<any>()
|
|
37
|
+
if (!user) throw new OAuthError('Unauthenticated.', 'invalid_request', 401)
|
|
38
|
+
|
|
39
|
+
const name = await request.input('name') as string | undefined
|
|
40
|
+
const redirect = await request.input('redirect') as string | undefined
|
|
41
|
+
|
|
42
|
+
if (!name) throw new OAuthError('The name field is required.', 'invalid_request')
|
|
43
|
+
if (!redirect) throw new OAuthError('The redirect field is required.', 'invalid_request')
|
|
44
|
+
|
|
45
|
+
const userId = typeof user.getAuthIdentifier === 'function'
|
|
46
|
+
? user.getAuthIdentifier()
|
|
47
|
+
: user.id ?? user.getAttribute?.('id')
|
|
48
|
+
|
|
49
|
+
const clientId = crypto.randomUUID()
|
|
50
|
+
const secret = crypto.randomUUID()
|
|
51
|
+
|
|
52
|
+
const client = await Client.create({
|
|
53
|
+
id: clientId,
|
|
54
|
+
user_id: String(userId),
|
|
55
|
+
name,
|
|
56
|
+
secret,
|
|
57
|
+
redirect,
|
|
58
|
+
personal_access_client: false,
|
|
59
|
+
password_client: false,
|
|
60
|
+
revoked: false,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// Include the secret in the creation response (only time it's visible)
|
|
64
|
+
const data = {
|
|
65
|
+
...client.toJSON(),
|
|
66
|
+
secret,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return new Response(JSON.stringify(data), {
|
|
70
|
+
status: 201,
|
|
71
|
+
headers: { 'Content-Type': 'application/json' },
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* PUT /oauth/clients/:id
|
|
77
|
+
* Update an existing OAuth client.
|
|
78
|
+
*/
|
|
79
|
+
async update(request: MantiqRequest): Promise<Response> {
|
|
80
|
+
const user = request.user<any>()
|
|
81
|
+
if (!user) throw new OAuthError('Unauthenticated.', 'invalid_request', 401)
|
|
82
|
+
|
|
83
|
+
const clientId = request.param('id')
|
|
84
|
+
if (!clientId) throw new OAuthError('Client ID is required.', 'invalid_request')
|
|
85
|
+
|
|
86
|
+
const client = await Client.find(clientId)
|
|
87
|
+
if (!client) throw new OAuthError('Client not found.', 'invalid_client', 404)
|
|
88
|
+
|
|
89
|
+
const userId = typeof user.getAuthIdentifier === 'function'
|
|
90
|
+
? user.getAuthIdentifier()
|
|
91
|
+
: user.id ?? user.getAttribute?.('id')
|
|
92
|
+
|
|
93
|
+
if (client.getAttribute('user_id') !== String(userId)) {
|
|
94
|
+
throw new OAuthError('This client does not belong to you.', 'invalid_client', 403)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const name = await request.input('name') as string | undefined
|
|
98
|
+
const redirect = await request.input('redirect') as string | undefined
|
|
99
|
+
|
|
100
|
+
if (name) client.setAttribute('name', name)
|
|
101
|
+
if (redirect) client.setAttribute('redirect', redirect)
|
|
102
|
+
|
|
103
|
+
await client.save()
|
|
104
|
+
|
|
105
|
+
return new Response(JSON.stringify(client.toJSON()), {
|
|
106
|
+
status: 200,
|
|
107
|
+
headers: { 'Content-Type': 'application/json' },
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* DELETE /oauth/clients/:id
|
|
113
|
+
* Delete an OAuth client.
|
|
114
|
+
*/
|
|
115
|
+
async destroy(request: MantiqRequest): Promise<Response> {
|
|
116
|
+
const user = request.user<any>()
|
|
117
|
+
if (!user) throw new OAuthError('Unauthenticated.', 'invalid_request', 401)
|
|
118
|
+
|
|
119
|
+
const clientId = request.param('id')
|
|
120
|
+
if (!clientId) throw new OAuthError('Client ID is required.', 'invalid_request')
|
|
121
|
+
|
|
122
|
+
const client = await Client.find(clientId)
|
|
123
|
+
if (!client) throw new OAuthError('Client not found.', 'invalid_client', 404)
|
|
124
|
+
|
|
125
|
+
const userId = typeof user.getAuthIdentifier === 'function'
|
|
126
|
+
? user.getAuthIdentifier()
|
|
127
|
+
: user.id ?? user.getAttribute?.('id')
|
|
128
|
+
|
|
129
|
+
if (client.getAttribute('user_id') !== String(userId)) {
|
|
130
|
+
throw new OAuthError('This client does not belong to you.', 'invalid_client', 403)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
client.setAttribute('revoked', true)
|
|
134
|
+
await client.save()
|
|
135
|
+
|
|
136
|
+
return new Response(null, { status: 204 })
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { MantiqRequest } from '@mantiq/core'
|
|
2
|
+
import type { OAuthServer } from '../OAuthServer.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GET /oauth/scopes
|
|
6
|
+
* Returns all registered OAuth scopes.
|
|
7
|
+
*/
|
|
8
|
+
export class ScopeController {
|
|
9
|
+
constructor(private readonly server: OAuthServer) {}
|
|
10
|
+
|
|
11
|
+
async index(_request: MantiqRequest): Promise<Response> {
|
|
12
|
+
const scopes = this.server.scopes()
|
|
13
|
+
|
|
14
|
+
return new Response(JSON.stringify(scopes), {
|
|
15
|
+
status: 200,
|
|
16
|
+
headers: { 'Content-Type': 'application/json' },
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { MantiqRequest } from '@mantiq/core'
|
|
2
|
+
import type { GrantHandler, OAuthTokenResponse } from '../grants/GrantHandler.ts'
|
|
3
|
+
import { OAuthError } from '../errors/OAuthError.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* POST /oauth/token
|
|
7
|
+
* Dispatches to the appropriate grant handler based on grant_type.
|
|
8
|
+
*/
|
|
9
|
+
export class TokenController {
|
|
10
|
+
private readonly grants = new Map<string, GrantHandler>()
|
|
11
|
+
|
|
12
|
+
registerGrant(grant: GrantHandler): void {
|
|
13
|
+
this.grants.set(grant.grantType, grant)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async issueToken(request: MantiqRequest): Promise<Response> {
|
|
17
|
+
const grantType = await request.input('grant_type') as string | undefined
|
|
18
|
+
if (!grantType) {
|
|
19
|
+
throw new OAuthError('The grant_type parameter is required.', 'invalid_request')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const handler = this.grants.get(grantType)
|
|
23
|
+
if (!handler) {
|
|
24
|
+
throw new OAuthError(`Unsupported grant type: ${grantType}`, 'unsupported_grant_type')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const tokenResponse: OAuthTokenResponse = await handler.handle(request)
|
|
28
|
+
|
|
29
|
+
return new Response(JSON.stringify(tokenResponse), {
|
|
30
|
+
status: 200,
|
|
31
|
+
headers: {
|
|
32
|
+
'Content-Type': 'application/json',
|
|
33
|
+
'Cache-Control': 'no-store',
|
|
34
|
+
'Pragma': 'no-cache',
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Router } from '@mantiq/core'
|
|
2
|
+
import type { OAuthServer } from '../OAuthServer.ts'
|
|
3
|
+
import { TokenController } from './TokenController.ts'
|
|
4
|
+
import { AuthorizationController } from './AuthorizationController.ts'
|
|
5
|
+
import { ClientController } from './ClientController.ts'
|
|
6
|
+
import { ScopeController } from './ScopeController.ts'
|
|
7
|
+
import type { GrantHandler } from '../grants/GrantHandler.ts'
|
|
8
|
+
|
|
9
|
+
export interface OAuthRouteOptions {
|
|
10
|
+
server: OAuthServer
|
|
11
|
+
grants: GrantHandler[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Register all OAuth routes on a Router instance.
|
|
16
|
+
*/
|
|
17
|
+
export function oauthRoutes(router: Router, options: OAuthRouteOptions): void {
|
|
18
|
+
const { server, grants } = options
|
|
19
|
+
|
|
20
|
+
// Token controller
|
|
21
|
+
const tokenController = new TokenController()
|
|
22
|
+
for (const grant of grants) {
|
|
23
|
+
tokenController.registerGrant(grant)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Authorization controller
|
|
27
|
+
const authorizationController = new AuthorizationController(server)
|
|
28
|
+
|
|
29
|
+
// Client controller
|
|
30
|
+
const clientController = new ClientController()
|
|
31
|
+
|
|
32
|
+
// Scope controller
|
|
33
|
+
const scopeController = new ScopeController(server)
|
|
34
|
+
|
|
35
|
+
// ── Token endpoint (public) ─────────────────────────────────────────────
|
|
36
|
+
router.post('/oauth/token', (req) => tokenController.issueToken(req))
|
|
37
|
+
|
|
38
|
+
// ── Authorization endpoints ─────────────────────────────────────────────
|
|
39
|
+
router.get('/oauth/authorize', (req) => authorizationController.authorize(req))
|
|
40
|
+
router.post('/oauth/authorize', (req) => authorizationController.approve(req))
|
|
41
|
+
.middleware('auth:oauth')
|
|
42
|
+
router.delete('/oauth/authorize', (req) => authorizationController.deny(req))
|
|
43
|
+
.middleware('auth:oauth')
|
|
44
|
+
|
|
45
|
+
// ── Client CRUD (requires auth) ────────────────────────────────────────
|
|
46
|
+
router.group({ prefix: '/oauth', middleware: ['auth:oauth'] }, (r) => {
|
|
47
|
+
r.get('/clients', (req) => clientController.index(req))
|
|
48
|
+
r.post('/clients', (req) => clientController.store(req))
|
|
49
|
+
r.put('/clients/:id', (req) => clientController.update(req))
|
|
50
|
+
r.delete('/clients/:id', (req) => clientController.destroy(req))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// ── Scopes endpoint ────────────────────────────────────────────────────
|
|
54
|
+
router.get('/oauth/scopes', (req) => scopeController.index(req))
|
|
55
|
+
}
|