@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.
Files changed (35) hide show
  1. package/package.json +41 -0
  2. package/src/OAuthServer.ts +75 -0
  3. package/src/OAuthServiceProvider.ts +89 -0
  4. package/src/commands/OAuthClientCommand.ts +50 -0
  5. package/src/commands/OAuthInstallCommand.ts +67 -0
  6. package/src/commands/OAuthKeysCommand.ts +43 -0
  7. package/src/commands/OAuthPurgeCommand.ts +89 -0
  8. package/src/errors/OAuthError.ts +22 -0
  9. package/src/grants/AuthCodeGrant.ts +159 -0
  10. package/src/grants/ClientCredentialsGrant.ts +79 -0
  11. package/src/grants/GrantHandler.ts +14 -0
  12. package/src/grants/PersonalAccessGrant.ts +72 -0
  13. package/src/grants/RefreshTokenGrant.ts +129 -0
  14. package/src/guards/JwtGuard.ts +95 -0
  15. package/src/helpers/oauth.ts +15 -0
  16. package/src/index.ts +58 -0
  17. package/src/jwt/JwtEncoder.ts +51 -0
  18. package/src/jwt/JwtPayload.ts +9 -0
  19. package/src/jwt/JwtSigner.ts +175 -0
  20. package/src/middleware/CheckClientCredentials.ts +60 -0
  21. package/src/middleware/CheckForAnyScope.ts +52 -0
  22. package/src/middleware/CheckScopes.ts +53 -0
  23. package/src/migrations/CreateOAuthAccessTokensTable.ts +21 -0
  24. package/src/migrations/CreateOAuthAuthCodesTable.ts +22 -0
  25. package/src/migrations/CreateOAuthClientsTable.ts +24 -0
  26. package/src/migrations/CreateOAuthRefreshTokensTable.ts +18 -0
  27. package/src/models/AccessToken.ts +52 -0
  28. package/src/models/AuthCode.ts +20 -0
  29. package/src/models/Client.ts +36 -0
  30. package/src/models/RefreshToken.ts +23 -0
  31. package/src/routes/AuthorizationController.ts +132 -0
  32. package/src/routes/ClientController.ts +138 -0
  33. package/src/routes/ScopeController.ts +19 -0
  34. package/src/routes/TokenController.ts +38 -0
  35. 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
+ }