@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,79 @@
1
+ import type { MantiqRequest } from '@mantiq/core'
2
+ import type { GrantHandler, OAuthTokenResponse } from './GrantHandler.ts'
3
+ import type { JwtSigner } from '../jwt/JwtSigner.ts'
4
+ import type { OAuthServer } from '../OAuthServer.ts'
5
+ import { Client } from '../models/Client.ts'
6
+ import { AccessToken } from '../models/AccessToken.ts'
7
+ import { OAuthError } from '../errors/OAuthError.ts'
8
+
9
+ /**
10
+ * Client Credentials grant — machine-to-machine authentication.
11
+ * No user involved, no refresh token issued.
12
+ */
13
+ export class ClientCredentialsGrant implements GrantHandler {
14
+ readonly grantType = 'client_credentials'
15
+
16
+ constructor(
17
+ private readonly signer: JwtSigner,
18
+ private readonly server: OAuthServer,
19
+ ) {}
20
+
21
+ async handle(request: MantiqRequest): Promise<OAuthTokenResponse> {
22
+ const clientId = await request.input('client_id') as string | undefined
23
+ const clientSecret = await request.input('client_secret') as string | undefined
24
+ const scopeParam = await request.input('scope') as string | undefined
25
+
26
+ if (!clientId) throw new OAuthError('The client_id parameter is required.', 'invalid_request')
27
+ if (!clientSecret) throw new OAuthError('The client_secret parameter is required.', 'invalid_request')
28
+
29
+ // Resolve client
30
+ const client = await Client.find(clientId)
31
+ if (!client) throw new OAuthError('Client not found.', 'invalid_client', 401)
32
+
33
+ // Verify secret
34
+ if (clientSecret !== client.getAttribute('secret')) {
35
+ throw new OAuthError('Invalid client credentials.', 'invalid_client', 401)
36
+ }
37
+
38
+ // Parse requested scopes
39
+ const scopes = scopeParam ? scopeParam.split(' ').filter(Boolean) : []
40
+
41
+ // Validate scopes
42
+ for (const scope of scopes) {
43
+ if (!this.server.hasScope(scope)) {
44
+ throw new OAuthError(`Invalid scope: ${scope}`, 'invalid_scope')
45
+ }
46
+ }
47
+
48
+ const tokenId = crypto.randomUUID()
49
+ const now = Math.floor(Date.now() / 1000)
50
+
51
+ // Create access token record (no user_id for client credentials)
52
+ await AccessToken.create({
53
+ id: tokenId,
54
+ user_id: null,
55
+ client_id: clientId,
56
+ name: null,
57
+ scopes: JSON.stringify(scopes),
58
+ revoked: false,
59
+ expires_at: new Date((now + this.server.tokenLifetime) * 1000).toISOString(),
60
+ })
61
+
62
+ // Sign JWT (no sub — no user)
63
+ const jwt = await this.signer.sign({
64
+ iss: 'mantiq-oauth',
65
+ aud: clientId,
66
+ exp: now + this.server.tokenLifetime,
67
+ iat: now,
68
+ jti: tokenId,
69
+ scopes,
70
+ })
71
+
72
+ return {
73
+ token_type: 'Bearer',
74
+ expires_in: this.server.tokenLifetime,
75
+ access_token: jwt,
76
+ scope: scopes.join(' ') || undefined,
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,14 @@
1
+ import type { MantiqRequest } from '@mantiq/core'
2
+
3
+ export interface OAuthTokenResponse {
4
+ token_type: 'Bearer'
5
+ expires_in: number
6
+ access_token: string
7
+ refresh_token?: string
8
+ scope?: string
9
+ }
10
+
11
+ export interface GrantHandler {
12
+ readonly grantType: string
13
+ handle(request: MantiqRequest): Promise<OAuthTokenResponse>
14
+ }
@@ -0,0 +1,72 @@
1
+ import type { MantiqRequest } from '@mantiq/core'
2
+ import type { GrantHandler, OAuthTokenResponse } from './GrantHandler.ts'
3
+ import type { JwtSigner } from '../jwt/JwtSigner.ts'
4
+ import type { OAuthServer } from '../OAuthServer.ts'
5
+ import { AccessToken } from '../models/AccessToken.ts'
6
+ import { OAuthError } from '../errors/OAuthError.ts'
7
+
8
+ /**
9
+ * Personal Access Token grant — for authenticated users creating
10
+ * their own API tokens with specific scopes.
11
+ */
12
+ export class PersonalAccessGrant implements GrantHandler {
13
+ readonly grantType = 'personal_access'
14
+
15
+ constructor(
16
+ private readonly signer: JwtSigner,
17
+ private readonly server: OAuthServer,
18
+ ) {}
19
+
20
+ async handle(request: MantiqRequest): Promise<OAuthTokenResponse> {
21
+ const user = request.user<any>()
22
+ if (!user) throw new OAuthError('User must be authenticated.', 'invalid_request', 401)
23
+
24
+ const scopeParam = await request.input('scope') as string | undefined
25
+ const name = await request.input('name') as string | undefined
26
+
27
+ // Parse requested scopes
28
+ const scopes = scopeParam ? scopeParam.split(' ').filter(Boolean) : []
29
+
30
+ // Validate scopes
31
+ for (const scope of scopes) {
32
+ if (!this.server.hasScope(scope)) {
33
+ throw new OAuthError(`Invalid scope: ${scope}`, 'invalid_scope')
34
+ }
35
+ }
36
+
37
+ const userId = typeof user.getAuthIdentifier === 'function'
38
+ ? user.getAuthIdentifier()
39
+ : user.id ?? user.getAttribute?.('id')
40
+
41
+ const tokenId = crypto.randomUUID()
42
+ const now = Math.floor(Date.now() / 1000)
43
+
44
+ // Create access token record
45
+ await AccessToken.create({
46
+ id: tokenId,
47
+ user_id: String(userId),
48
+ client_id: null,
49
+ name: name ?? 'Personal Access Token',
50
+ scopes: JSON.stringify(scopes),
51
+ revoked: false,
52
+ expires_at: new Date((now + this.server.tokenLifetime) * 1000).toISOString(),
53
+ })
54
+
55
+ // Sign JWT
56
+ const jwt = await this.signer.sign({
57
+ iss: 'mantiq-oauth',
58
+ sub: String(userId),
59
+ exp: now + this.server.tokenLifetime,
60
+ iat: now,
61
+ jti: tokenId,
62
+ scopes,
63
+ })
64
+
65
+ return {
66
+ token_type: 'Bearer',
67
+ expires_in: this.server.tokenLifetime,
68
+ access_token: jwt,
69
+ scope: scopes.join(' ') || undefined,
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,129 @@
1
+ import type { MantiqRequest } from '@mantiq/core'
2
+ import type { GrantHandler, OAuthTokenResponse } from './GrantHandler.ts'
3
+ import type { JwtSigner } from '../jwt/JwtSigner.ts'
4
+ import type { OAuthServer } from '../OAuthServer.ts'
5
+ import { Client } from '../models/Client.ts'
6
+ import { AccessToken } from '../models/AccessToken.ts'
7
+ import { RefreshToken } from '../models/RefreshToken.ts'
8
+ import { OAuthError } from '../errors/OAuthError.ts'
9
+
10
+ /**
11
+ * Refresh Token grant — exchange a refresh token for a new token pair.
12
+ * Revokes the old access + refresh tokens and issues new ones.
13
+ */
14
+ export class RefreshTokenGrant implements GrantHandler {
15
+ readonly grantType = 'refresh_token'
16
+
17
+ constructor(
18
+ private readonly signer: JwtSigner,
19
+ private readonly server: OAuthServer,
20
+ ) {}
21
+
22
+ async handle(request: MantiqRequest): Promise<OAuthTokenResponse> {
23
+ const refreshTokenId = await request.input('refresh_token') as string | undefined
24
+ const clientId = await request.input('client_id') as string | undefined
25
+ const clientSecret = await request.input('client_secret') as string | undefined
26
+ const scopeParam = await request.input('scope') as string | undefined
27
+
28
+ if (!refreshTokenId) throw new OAuthError('The refresh_token parameter is required.', 'invalid_request')
29
+ if (!clientId) throw new OAuthError('The client_id parameter is required.', 'invalid_request')
30
+
31
+ // Resolve client
32
+ const client = await Client.find(clientId)
33
+ if (!client) throw new OAuthError('Client not found.', 'invalid_client', 401)
34
+
35
+ // Verify secret for confidential clients
36
+ if (client.confidential()) {
37
+ if (!clientSecret || clientSecret !== client.getAttribute('secret')) {
38
+ throw new OAuthError('Invalid client credentials.', 'invalid_client', 401)
39
+ }
40
+ }
41
+
42
+ // Resolve refresh token
43
+ const refreshToken = await RefreshToken.find(refreshTokenId)
44
+ if (!refreshToken) throw new OAuthError('Invalid refresh token.', 'invalid_grant')
45
+
46
+ if (refreshToken.getAttribute('revoked')) {
47
+ throw new OAuthError('Refresh token has been revoked.', 'invalid_grant')
48
+ }
49
+
50
+ // Check expiration
51
+ const expiresAt = refreshToken.getAttribute('expires_at')
52
+ if (expiresAt && new Date(expiresAt) < new Date()) {
53
+ throw new OAuthError('Refresh token has expired.', 'invalid_grant')
54
+ }
55
+
56
+ // Resolve the original access token
57
+ const oldAccessTokenId = refreshToken.getAttribute('access_token_id') as string
58
+ const oldAccessToken = await AccessToken.find(oldAccessTokenId)
59
+ if (!oldAccessToken) throw new OAuthError('Associated access token not found.', 'invalid_grant')
60
+
61
+ // Verify the token belongs to this client
62
+ if (oldAccessToken.getAttribute('client_id') !== clientId) {
63
+ throw new OAuthError('Refresh token does not belong to this client.', 'invalid_grant')
64
+ }
65
+
66
+ // Revoke old tokens
67
+ await refreshToken.revoke()
68
+ if (!oldAccessToken.getAttribute('revoked')) {
69
+ await oldAccessToken.revoke()
70
+ }
71
+
72
+ // Determine scopes (keep original scopes, or narrow if requested)
73
+ const originalScopes = (oldAccessToken.getAttribute('scopes') as string[]) || []
74
+ let scopes = originalScopes
75
+
76
+ if (scopeParam) {
77
+ const requestedScopes = scopeParam.split(' ').filter(Boolean)
78
+ // Can only request a subset of original scopes
79
+ for (const scope of requestedScopes) {
80
+ if (!originalScopes.includes('*') && !originalScopes.includes(scope)) {
81
+ throw new OAuthError(`Scope "${scope}" was not granted on the original token.`, 'invalid_scope')
82
+ }
83
+ }
84
+ scopes = requestedScopes
85
+ }
86
+
87
+ // Issue new tokens
88
+ const userId = oldAccessToken.getAttribute('user_id') as string | null
89
+ const tokenId = crypto.randomUUID()
90
+ const now = Math.floor(Date.now() / 1000)
91
+
92
+ await AccessToken.create({
93
+ id: tokenId,
94
+ user_id: userId,
95
+ client_id: clientId,
96
+ name: null,
97
+ scopes: JSON.stringify(scopes),
98
+ revoked: false,
99
+ expires_at: new Date((now + this.server.tokenLifetime) * 1000).toISOString(),
100
+ })
101
+
102
+ const newRefreshTokenId = crypto.randomUUID()
103
+ await RefreshToken.create({
104
+ id: newRefreshTokenId,
105
+ access_token_id: tokenId,
106
+ revoked: false,
107
+ expires_at: new Date((now + this.server.refreshTokenLifetime) * 1000).toISOString(),
108
+ })
109
+
110
+ // Sign JWT
111
+ const jwt = await this.signer.sign({
112
+ iss: 'mantiq-oauth',
113
+ sub: userId ?? undefined,
114
+ aud: clientId,
115
+ exp: now + this.server.tokenLifetime,
116
+ iat: now,
117
+ jti: tokenId,
118
+ scopes,
119
+ })
120
+
121
+ return {
122
+ token_type: 'Bearer',
123
+ expires_in: this.server.tokenLifetime,
124
+ access_token: jwt,
125
+ refresh_token: newRefreshTokenId,
126
+ scope: scopes.join(' '),
127
+ }
128
+ }
129
+ }
@@ -0,0 +1,95 @@
1
+ import type { Guard } from '@mantiq/auth'
2
+ import type { Authenticatable, UserProvider } from '@mantiq/auth'
3
+ import type { MantiqRequest } from '@mantiq/core'
4
+ import type { JwtSigner } from '../jwt/JwtSigner.ts'
5
+ import { AccessToken } from '../models/AccessToken.ts'
6
+
7
+ /**
8
+ * JWT-based stateless guard for OAuth access tokens.
9
+ * Extracts the bearer token, verifies the JWT signature,
10
+ * checks the token record in the database, and resolves the user.
11
+ */
12
+ export class JwtGuard implements Guard {
13
+ private _user: Authenticatable | null = null
14
+ private _request: MantiqRequest | null = null
15
+ private _resolved = false
16
+
17
+ constructor(
18
+ private readonly signer: JwtSigner,
19
+ private readonly provider: UserProvider,
20
+ ) {}
21
+
22
+ async check(): Promise<boolean> {
23
+ return (await this.user()) !== null
24
+ }
25
+
26
+ async guest(): Promise<boolean> {
27
+ return !(await this.check())
28
+ }
29
+
30
+ async user(): Promise<Authenticatable | null> {
31
+ if (this._resolved) return this._user
32
+ this._resolved = true
33
+
34
+ if (!this._request) return null
35
+
36
+ const bearerToken = this._request.bearerToken()
37
+ if (!bearerToken) return null
38
+
39
+ // Verify JWT signature and expiration
40
+ const payload = await this.signer.verify(bearerToken)
41
+ if (!payload || !payload.jti) return null
42
+
43
+ // Look up the access token in the database
44
+ const accessToken = await AccessToken.find(payload.jti)
45
+ if (!accessToken) return null
46
+
47
+ // Check if revoked
48
+ if (accessToken.getAttribute('revoked')) return null
49
+
50
+ // Check if expired (belt-and-suspenders — JWT exp is already checked)
51
+ if (accessToken.isExpired()) return null
52
+
53
+ // For client_credentials tokens with no user
54
+ const userId = payload.sub
55
+ if (!userId) return null
56
+
57
+ // Resolve user from provider
58
+ const user = await this.provider.retrieveById(userId)
59
+ if (!user) return null
60
+
61
+ // Attach token info to the user if it supports it
62
+ if (typeof (user as any).withAccessToken === 'function') {
63
+ (user as any).withAccessToken(accessToken)
64
+ }
65
+
66
+ this._user = user
67
+ return user
68
+ }
69
+
70
+ async id(): Promise<string | number | null> {
71
+ const user = await this.user()
72
+ return user?.getAuthIdentifier() ?? null
73
+ }
74
+
75
+ async validate(credentials: Record<string, any>): Promise<boolean> {
76
+ const user = await this.provider.retrieveByCredentials(credentials)
77
+ if (!user) return false
78
+ return this.provider.validateCredentials(user, credentials)
79
+ }
80
+
81
+ setUser(user: Authenticatable): void {
82
+ this._user = user
83
+ this._resolved = true
84
+ }
85
+
86
+ hasUser(): boolean {
87
+ return this._user !== null
88
+ }
89
+
90
+ setRequest(request: MantiqRequest): void {
91
+ this._request = request
92
+ this._user = null
93
+ this._resolved = false
94
+ }
95
+ }
@@ -0,0 +1,15 @@
1
+ import { Application } from '@mantiq/core'
2
+ import type { OAuthServer } from '../OAuthServer.ts'
3
+
4
+ export const OAUTH_SERVER = Symbol('OAuthServer')
5
+
6
+ /**
7
+ * Access the OAuthServer singleton.
8
+ *
9
+ * @example
10
+ * const server = oauth()
11
+ * server.tokensCan({ 'read': 'Read data', 'write': 'Write data' })
12
+ */
13
+ export function oauth(): OAuthServer {
14
+ return Application.getInstance().make<OAuthServer>(OAUTH_SERVER)
15
+ }
package/src/index.ts ADDED
@@ -0,0 +1,58 @@
1
+ // ── JWT ──────────────────────────────────────────────────────────────────────
2
+ export type { JwtPayload } from './jwt/JwtPayload.ts'
3
+ export { base64UrlEncode, base64UrlDecode, base64UrlEncodeString, base64UrlDecodeString } from './jwt/JwtEncoder.ts'
4
+ export { JwtSigner } from './jwt/JwtSigner.ts'
5
+
6
+ // ── Models ───────────────────────────────────────────────────────────────────
7
+ export { Client } from './models/Client.ts'
8
+ export { AccessToken } from './models/AccessToken.ts'
9
+ export { AuthCode } from './models/AuthCode.ts'
10
+ export { RefreshToken } from './models/RefreshToken.ts'
11
+
12
+ // ── Server ───────────────────────────────────────────────────────────────────
13
+ export { OAuthServer } from './OAuthServer.ts'
14
+ export type { OAuthConfig } from './OAuthServer.ts'
15
+
16
+ // ── Grants ───────────────────────────────────────────────────────────────────
17
+ export type { GrantHandler, OAuthTokenResponse } from './grants/GrantHandler.ts'
18
+ export { AuthCodeGrant } from './grants/AuthCodeGrant.ts'
19
+ export { ClientCredentialsGrant } from './grants/ClientCredentialsGrant.ts'
20
+ export { RefreshTokenGrant } from './grants/RefreshTokenGrant.ts'
21
+ export { PersonalAccessGrant } from './grants/PersonalAccessGrant.ts'
22
+
23
+ // ── Guards ───────────────────────────────────────────────────────────────────
24
+ export { JwtGuard } from './guards/JwtGuard.ts'
25
+
26
+ // ── Middleware ────────────────────────────────────────────────────────────────
27
+ export { CheckScopes } from './middleware/CheckScopes.ts'
28
+ export { CheckForAnyScope } from './middleware/CheckForAnyScope.ts'
29
+ export { CheckClientCredentials } from './middleware/CheckClientCredentials.ts'
30
+
31
+ // ── Routes ───────────────────────────────────────────────────────────────────
32
+ export { oauthRoutes } from './routes/oauthRoutes.ts'
33
+ export type { OAuthRouteOptions } from './routes/oauthRoutes.ts'
34
+ export { TokenController } from './routes/TokenController.ts'
35
+ export { AuthorizationController } from './routes/AuthorizationController.ts'
36
+ export { ClientController } from './routes/ClientController.ts'
37
+ export { ScopeController } from './routes/ScopeController.ts'
38
+
39
+ // ── Commands ─────────────────────────────────────────────────────────────────
40
+ export { OAuthInstallCommand } from './commands/OAuthInstallCommand.ts'
41
+ export { OAuthKeysCommand } from './commands/OAuthKeysCommand.ts'
42
+ export { OAuthClientCommand } from './commands/OAuthClientCommand.ts'
43
+ export { OAuthPurgeCommand } from './commands/OAuthPurgeCommand.ts'
44
+
45
+ // ── Migrations ───────────────────────────────────────────────────────────────
46
+ export { CreateOAuthClientsTable } from './migrations/CreateOAuthClientsTable.ts'
47
+ export { CreateOAuthAccessTokensTable } from './migrations/CreateOAuthAccessTokensTable.ts'
48
+ export { CreateOAuthAuthCodesTable } from './migrations/CreateOAuthAuthCodesTable.ts'
49
+ export { CreateOAuthRefreshTokensTable } from './migrations/CreateOAuthRefreshTokensTable.ts'
50
+
51
+ // ── Errors ───────────────────────────────────────────────────────────────────
52
+ export { OAuthError } from './errors/OAuthError.ts'
53
+
54
+ // ── Helpers ──────────────────────────────────────────────────────────────────
55
+ export { oauth, OAUTH_SERVER } from './helpers/oauth.ts'
56
+
57
+ // ── Service Provider ─────────────────────────────────────────────────────────
58
+ export { OAuthServiceProvider } from './OAuthServiceProvider.ts'
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Base64URL encode/decode utilities for JWT.
3
+ * No external dependencies — uses standard APIs only.
4
+ */
5
+
6
+ /**
7
+ * Encode a Uint8Array to a Base64URL string (no padding).
8
+ */
9
+ export function base64UrlEncode(data: Uint8Array): string {
10
+ let binary = ''
11
+ for (let i = 0; i < data.length; i++) {
12
+ binary += String.fromCharCode(data[i]!)
13
+ }
14
+ return btoa(binary)
15
+ .replace(/\+/g, '-')
16
+ .replace(/\//g, '_')
17
+ .replace(/=+$/, '')
18
+ }
19
+
20
+ /**
21
+ * Decode a Base64URL string to a Uint8Array.
22
+ */
23
+ export function base64UrlDecode(str: string): Uint8Array {
24
+ // Restore standard Base64 characters
25
+ let base64 = str.replace(/-/g, '+').replace(/_/g, '/')
26
+ // Restore padding
27
+ const pad = base64.length % 4
28
+ if (pad === 2) base64 += '=='
29
+ else if (pad === 3) base64 += '='
30
+
31
+ const binary = atob(base64)
32
+ const bytes = new Uint8Array(binary.length)
33
+ for (let i = 0; i < binary.length; i++) {
34
+ bytes[i] = binary.charCodeAt(i)
35
+ }
36
+ return bytes
37
+ }
38
+
39
+ /**
40
+ * Encode a UTF-8 string to Base64URL.
41
+ */
42
+ export function base64UrlEncodeString(str: string): string {
43
+ return base64UrlEncode(new TextEncoder().encode(str))
44
+ }
45
+
46
+ /**
47
+ * Decode a Base64URL string to a UTF-8 string.
48
+ */
49
+ export function base64UrlDecodeString(str: string): string {
50
+ return new TextDecoder().decode(base64UrlDecode(str))
51
+ }
@@ -0,0 +1,9 @@
1
+ export interface JwtPayload {
2
+ iss?: string
3
+ sub?: string
4
+ aud?: string
5
+ exp?: number
6
+ iat?: number
7
+ jti?: string
8
+ scopes?: string[]
9
+ }