@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
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@mantiq/oauth",
3
+ "version": "0.1.0",
4
+ "description": "OAuth 2.0 server — authorization code (PKCE), client credentials, JWT access tokens, scopes",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Abdullah Khan",
8
+ "homepage": "https://github.com/mantiqjs/mantiq/tree/main/packages/oauth",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/mantiqjs/mantiq.git",
12
+ "directory": "packages/oauth"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/mantiqjs/mantiq/issues"
16
+ },
17
+ "keywords": ["mantiq", "oauth", "oauth2", "jwt", "authorization", "token"],
18
+ "engines": { "bun": ">=1.1.0" },
19
+ "main": "./src/index.ts",
20
+ "types": "./src/index.ts",
21
+ "exports": { ".": { "bun": "./src/index.ts", "default": "./src/index.ts" } },
22
+ "files": ["src/", "package.json", "README.md"],
23
+ "scripts": {
24
+ "build": "bun build ./src/index.ts --outdir ./dist --target bun --packages=external",
25
+ "test": "bun test",
26
+ "typecheck": "tsc --noEmit",
27
+ "clean": "rm -rf dist"
28
+ },
29
+ "peerDependencies": {
30
+ "@mantiq/core": "^0.2.0",
31
+ "@mantiq/database": "^0.2.0",
32
+ "@mantiq/auth": "^0.2.0"
33
+ },
34
+ "devDependencies": {
35
+ "bun-types": "latest",
36
+ "typescript": "^5.7.0",
37
+ "@mantiq/core": "workspace:*",
38
+ "@mantiq/database": "workspace:*",
39
+ "@mantiq/auth": "workspace:*"
40
+ }
41
+ }
@@ -0,0 +1,75 @@
1
+ export interface OAuthConfig {
2
+ /**
3
+ * Lifetime of access tokens in seconds.
4
+ * @default 3600 (1 hour)
5
+ */
6
+ tokenLifetime?: number
7
+
8
+ /**
9
+ * Lifetime of refresh tokens in seconds.
10
+ * @default 1209600 (14 days)
11
+ */
12
+ refreshTokenLifetime?: number
13
+
14
+ /**
15
+ * Path to the RSA private key PEM file.
16
+ */
17
+ privateKeyPath?: string
18
+
19
+ /**
20
+ * Path to the RSA public key PEM file.
21
+ */
22
+ publicKeyPath?: string
23
+ }
24
+
25
+ /**
26
+ * Central OAuth server configuration holder.
27
+ * Manages scopes and token lifetimes.
28
+ */
29
+ export class OAuthServer {
30
+ private _scopes = new Map<string, string>()
31
+
32
+ constructor(private config: OAuthConfig) {}
33
+
34
+ get tokenLifetime(): number {
35
+ return this.config.tokenLifetime ?? 3600
36
+ }
37
+
38
+ get refreshTokenLifetime(): number {
39
+ return this.config.refreshTokenLifetime ?? 1209600
40
+ }
41
+
42
+ get privateKeyPath(): string {
43
+ return this.config.privateKeyPath ?? 'storage/oauth-private.key'
44
+ }
45
+
46
+ get publicKeyPath(): string {
47
+ return this.config.publicKeyPath ?? 'storage/oauth-public.key'
48
+ }
49
+
50
+ /**
51
+ * Register scopes that tokens can be issued with.
52
+ */
53
+ tokensCan(scopes: Record<string, string>): void {
54
+ for (const [id, description] of Object.entries(scopes)) {
55
+ this._scopes.set(id, description)
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Check if a scope is registered.
61
+ */
62
+ hasScope(scope: string): boolean {
63
+ return this._scopes.has(scope)
64
+ }
65
+
66
+ /**
67
+ * Get all registered scopes.
68
+ */
69
+ scopes(): Array<{ id: string; description: string }> {
70
+ return Array.from(this._scopes.entries()).map(([id, description]) => ({
71
+ id,
72
+ description,
73
+ }))
74
+ }
75
+ }
@@ -0,0 +1,89 @@
1
+ import { ServiceProvider, ConfigRepository } from '@mantiq/core'
2
+ import type { Router } from '@mantiq/core'
3
+ import { ROUTER } from '@mantiq/core'
4
+ import { AuthManager } from '@mantiq/auth'
5
+ import { OAuthServer } from './OAuthServer.ts'
6
+ import type { OAuthConfig } from './OAuthServer.ts'
7
+ import { JwtSigner } from './jwt/JwtSigner.ts'
8
+ import { JwtGuard } from './guards/JwtGuard.ts'
9
+ import { CheckScopes } from './middleware/CheckScopes.ts'
10
+ import { CheckForAnyScope } from './middleware/CheckForAnyScope.ts'
11
+ import { CheckClientCredentials } from './middleware/CheckClientCredentials.ts'
12
+ import { AuthCodeGrant } from './grants/AuthCodeGrant.ts'
13
+ import { ClientCredentialsGrant } from './grants/ClientCredentialsGrant.ts'
14
+ import { RefreshTokenGrant } from './grants/RefreshTokenGrant.ts'
15
+ import { PersonalAccessGrant } from './grants/PersonalAccessGrant.ts'
16
+ import { oauthRoutes } from './routes/oauthRoutes.ts'
17
+ import { OAUTH_SERVER } from './helpers/oauth.ts'
18
+ import { readFile } from 'node:fs/promises'
19
+
20
+ const DEFAULT_CONFIG: OAuthConfig = {
21
+ tokenLifetime: 3600,
22
+ refreshTokenLifetime: 1209600,
23
+ privateKeyPath: 'storage/oauth-private.key',
24
+ publicKeyPath: 'storage/oauth-public.key',
25
+ }
26
+
27
+ /**
28
+ * Service provider for the OAuth package.
29
+ *
30
+ * register(): binds OAuthServer + JwtSigner as singletons
31
+ * boot(): loads keys, registers the 'oauth' guard on AuthManager, registers routes, binds middleware
32
+ */
33
+ export class OAuthServiceProvider extends ServiceProvider {
34
+ override register(): void {
35
+ // OAuthServer singleton
36
+ this.app.singleton(OAuthServer, (c) => {
37
+ const config = c.make(ConfigRepository).get<OAuthConfig>('oauth', DEFAULT_CONFIG)
38
+ return new OAuthServer(config)
39
+ })
40
+ this.app.alias(OAuthServer, OAUTH_SERVER)
41
+
42
+ // JwtSigner singleton
43
+ this.app.singleton(JwtSigner, () => new JwtSigner())
44
+
45
+ // Middleware bindings
46
+ this.app.bind(CheckScopes, () => new CheckScopes())
47
+ this.app.bind(CheckForAnyScope, () => new CheckForAnyScope())
48
+ this.app.bind(CheckClientCredentials, (c) => new CheckClientCredentials(c.make(JwtSigner)))
49
+ }
50
+
51
+ override async boot(): Promise<void> {
52
+ const server = this.app.make(OAuthServer)
53
+ const signer = this.app.make(JwtSigner)
54
+
55
+ // Load RSA keys if they exist
56
+ try {
57
+ const privateKey = await readFile(server.privateKeyPath, 'utf-8')
58
+ const publicKey = await readFile(server.publicKeyPath, 'utf-8')
59
+ await signer.loadKeys(privateKey, publicKey)
60
+ } catch {
61
+ // Keys not yet generated — oauth:install must be run first
62
+ }
63
+
64
+ // Register 'oauth' guard on AuthManager
65
+ try {
66
+ const authManager = this.app.make(AuthManager)
67
+ authManager.extend('oauth', () => {
68
+ const provider = authManager.createUserProvider('users')
69
+ return new JwtGuard(signer, provider)
70
+ })
71
+ } catch {
72
+ // AuthManager not available — @mantiq/auth may not be installed
73
+ }
74
+
75
+ // Register OAuth routes
76
+ try {
77
+ const router = this.app.make<Router>(ROUTER)
78
+ const grants = [
79
+ new AuthCodeGrant(signer, server),
80
+ new ClientCredentialsGrant(signer, server),
81
+ new RefreshTokenGrant(signer, server),
82
+ new PersonalAccessGrant(signer, server),
83
+ ]
84
+ oauthRoutes(router, { server, grants })
85
+ } catch {
86
+ // Router not available
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,50 @@
1
+ import { Command } from '@mantiq/cli'
2
+ import type { ParsedArgs } from '@mantiq/cli'
3
+ import { Client } from '../models/Client.ts'
4
+
5
+ /**
6
+ * Create a new OAuth client.
7
+ *
8
+ * Usage: mantiq oauth:client <name> [--redirect=URL] [--personal] [--password]
9
+ */
10
+ export class OAuthClientCommand extends Command {
11
+ override name = 'oauth:client'
12
+ override description = 'Create a new OAuth client'
13
+ override usage = 'oauth:client <name> [--redirect=URL] [--personal] [--password]'
14
+
15
+ override async handle(args: ParsedArgs): Promise<number> {
16
+ const name = args.args[0]
17
+ if (!name) {
18
+ this.io.error('Client name is required.')
19
+ this.io.muted(' Usage: mantiq oauth:client <name> [--redirect=URL]')
20
+ return 1
21
+ }
22
+
23
+ const redirect = (args.flags['redirect'] as string) || 'http://localhost'
24
+ const personal = !!args.flags['personal']
25
+ const password = !!args.flags['password']
26
+
27
+ const clientId = crypto.randomUUID()
28
+ const secret = crypto.randomUUID()
29
+
30
+ await Client.create({
31
+ id: clientId,
32
+ name,
33
+ secret,
34
+ redirect,
35
+ personal_access_client: personal,
36
+ password_client: password,
37
+ revoked: false,
38
+ })
39
+
40
+ this.io.success(`OAuth client "${name}" created successfully.`)
41
+ this.io.newLine()
42
+ this.io.twoColumn('Client ID:', clientId)
43
+ this.io.twoColumn('Client Secret:', secret)
44
+ this.io.twoColumn('Redirect URL:', redirect)
45
+ this.io.twoColumn('Personal Access:', personal ? 'Yes' : 'No')
46
+ this.io.twoColumn('Password Grant:', password ? 'Yes' : 'No')
47
+
48
+ return 0
49
+ }
50
+ }
@@ -0,0 +1,67 @@
1
+ import { Command } from '@mantiq/cli'
2
+ import type { ParsedArgs } from '@mantiq/cli'
3
+ import { JwtSigner } from '../jwt/JwtSigner.ts'
4
+ import { Client } from '../models/Client.ts'
5
+ import { writeFile } from 'node:fs/promises'
6
+ import { existsSync } from 'node:fs'
7
+
8
+ /**
9
+ * Generate OAuth RSA keys and create a personal access client.
10
+ *
11
+ * Usage: mantiq oauth:install
12
+ */
13
+ export class OAuthInstallCommand extends Command {
14
+ override name = 'oauth:install'
15
+ override description = 'Run the commands necessary to prepare OAuth for use'
16
+
17
+ override async handle(_args: ParsedArgs): Promise<number> {
18
+ this.io.info('Installing OAuth server...')
19
+
20
+ // Generate keys
21
+ const signer = new JwtSigner()
22
+ const keyPair = await signer.generateKeyPair()
23
+
24
+ const privatePath = 'storage/oauth-private.key'
25
+ const publicPath = 'storage/oauth-public.key'
26
+
27
+ const privateExists = existsSync(privatePath)
28
+ const publicExists = existsSync(publicPath)
29
+
30
+ if (privateExists || publicExists) {
31
+ this.io.warn('OAuth keys already exist. Skipping key generation.')
32
+ } else {
33
+ await writeFile(privatePath, keyPair.privateKey, 'utf-8')
34
+ await writeFile(publicPath, keyPair.publicKey, 'utf-8')
35
+ this.io.success('OAuth keys generated successfully.')
36
+ this.io.muted(` Private key: ${privatePath}`)
37
+ this.io.muted(` Public key: ${publicPath}`)
38
+ }
39
+
40
+ // Create personal access client
41
+ const existingClient = await Client.where('personal_access_client', true).first()
42
+ if (existingClient) {
43
+ this.io.warn('Personal access client already exists. Skipping.')
44
+ } else {
45
+ const clientId = crypto.randomUUID()
46
+ const secret = crypto.randomUUID()
47
+
48
+ await Client.create({
49
+ id: clientId,
50
+ name: 'Personal Access Client',
51
+ secret,
52
+ redirect: 'http://localhost',
53
+ personal_access_client: true,
54
+ password_client: false,
55
+ revoked: false,
56
+ })
57
+
58
+ this.io.success('Personal access client created successfully.')
59
+ this.io.muted(` Client ID: ${clientId}`)
60
+ this.io.muted(` Client Secret: ${secret}`)
61
+ }
62
+
63
+ this.io.newLine()
64
+ this.io.success('OAuth installation complete.')
65
+ return 0
66
+ }
67
+ }
@@ -0,0 +1,43 @@
1
+ import { Command } from '@mantiq/cli'
2
+ import type { ParsedArgs } from '@mantiq/cli'
3
+ import { JwtSigner } from '../jwt/JwtSigner.ts'
4
+ import { writeFile } from 'node:fs/promises'
5
+ import { existsSync } from 'node:fs'
6
+
7
+ /**
8
+ * Generate the RSA key pair for signing JWTs.
9
+ *
10
+ * Usage: mantiq oauth:keys [--force]
11
+ */
12
+ export class OAuthKeysCommand extends Command {
13
+ override name = 'oauth:keys'
14
+ override description = 'Generate the encryption keys for API authentication'
15
+ override usage = 'oauth:keys [--force]'
16
+
17
+ override async handle(args: ParsedArgs): Promise<number> {
18
+ const force = !!args.flags['force']
19
+ const privatePath = 'storage/oauth-private.key'
20
+ const publicPath = 'storage/oauth-public.key'
21
+
22
+ if (!force) {
23
+ if (existsSync(privatePath) || existsSync(publicPath)) {
24
+ this.io.error('OAuth keys already exist. Use --force to overwrite.')
25
+ return 1
26
+ }
27
+ }
28
+
29
+ this.io.info('Generating RSA key pair...')
30
+
31
+ const signer = new JwtSigner()
32
+ const keyPair = await signer.generateKeyPair()
33
+
34
+ await writeFile(privatePath, keyPair.privateKey, 'utf-8')
35
+ await writeFile(publicPath, keyPair.publicKey, 'utf-8')
36
+
37
+ this.io.success('OAuth keys generated successfully.')
38
+ this.io.muted(` Private key: ${privatePath}`)
39
+ this.io.muted(` Public key: ${publicPath}`)
40
+
41
+ return 0
42
+ }
43
+ }
@@ -0,0 +1,89 @@
1
+ import { Command } from '@mantiq/cli'
2
+ import type { ParsedArgs } from '@mantiq/cli'
3
+ import { AccessToken } from '../models/AccessToken.ts'
4
+ import { RefreshToken } from '../models/RefreshToken.ts'
5
+ import { AuthCode } from '../models/AuthCode.ts'
6
+
7
+ /**
8
+ * Purge expired and revoked OAuth tokens.
9
+ *
10
+ * Usage: mantiq oauth:purge [--revoked]
11
+ */
12
+ export class OAuthPurgeCommand extends Command {
13
+ override name = 'oauth:purge'
14
+ override description = 'Purge revoked and/or expired tokens and auth codes'
15
+ override usage = 'oauth:purge [--revoked]'
16
+
17
+ override async handle(args: ParsedArgs): Promise<number> {
18
+ const revokedOnly = !!args.flags['revoked']
19
+
20
+ this.io.info('Purging OAuth tokens...')
21
+
22
+ const now = new Date().toISOString()
23
+ let purgedTokens = 0
24
+ let purgedRefresh = 0
25
+ let purgedCodes = 0
26
+
27
+ if (revokedOnly) {
28
+ // Only purge revoked tokens
29
+ const revokedAccessTokens = await AccessToken.where('revoked', true).get()
30
+ for (const token of revokedAccessTokens) {
31
+ await token.delete()
32
+ purgedTokens++
33
+ }
34
+
35
+ const revokedRefreshTokens = await RefreshToken.where('revoked', true).get()
36
+ for (const token of revokedRefreshTokens) {
37
+ await token.delete()
38
+ purgedRefresh++
39
+ }
40
+
41
+ const revokedAuthCodes = await AuthCode.where('revoked', true).get()
42
+ for (const code of revokedAuthCodes) {
43
+ await code.delete()
44
+ purgedCodes++
45
+ }
46
+ } else {
47
+ // Purge expired + revoked
48
+ const expiredAccessTokens = await AccessToken.where('expires_at', '<', now).get()
49
+ const revokedAccessTokens = await AccessToken.where('revoked', true).get()
50
+ const allAccessTokens = new Map<string, typeof expiredAccessTokens[0]>()
51
+ for (const t of [...expiredAccessTokens, ...revokedAccessTokens]) {
52
+ allAccessTokens.set(t.getKey() as string, t)
53
+ }
54
+ for (const token of allAccessTokens.values()) {
55
+ await token.delete()
56
+ purgedTokens++
57
+ }
58
+
59
+ const expiredRefreshTokens = await RefreshToken.where('expires_at', '<', now).get()
60
+ const revokedRefreshTokens = await RefreshToken.where('revoked', true).get()
61
+ const allRefreshTokens = new Map<string, typeof expiredRefreshTokens[0]>()
62
+ for (const t of [...expiredRefreshTokens, ...revokedRefreshTokens]) {
63
+ allRefreshTokens.set(t.getKey() as string, t)
64
+ }
65
+ for (const token of allRefreshTokens.values()) {
66
+ await token.delete()
67
+ purgedRefresh++
68
+ }
69
+
70
+ const expiredAuthCodes = await AuthCode.where('expires_at', '<', now).get()
71
+ const revokedAuthCodes = await AuthCode.where('revoked', true).get()
72
+ const allAuthCodes = new Map<string, typeof expiredAuthCodes[0]>()
73
+ for (const c of [...expiredAuthCodes, ...revokedAuthCodes]) {
74
+ allAuthCodes.set(c.getKey() as string, c)
75
+ }
76
+ for (const code of allAuthCodes.values()) {
77
+ await code.delete()
78
+ purgedCodes++
79
+ }
80
+ }
81
+
82
+ this.io.success('Purge complete.')
83
+ this.io.twoColumn('Access tokens purged:', String(purgedTokens))
84
+ this.io.twoColumn('Refresh tokens purged:', String(purgedRefresh))
85
+ this.io.twoColumn('Auth codes purged:', String(purgedCodes))
86
+
87
+ return 0
88
+ }
89
+ }
@@ -0,0 +1,22 @@
1
+ import { HttpError } from '@mantiq/core'
2
+
3
+ /**
4
+ * OAuth-specific error.
5
+ * Defaults to 400 Bad Request. Use a different status code for specific cases.
6
+ */
7
+ export class OAuthError extends HttpError {
8
+ constructor(
9
+ message: string,
10
+ public readonly errorCode: string = 'invalid_request',
11
+ statusCode = 400,
12
+ ) {
13
+ super(statusCode, message)
14
+ }
15
+
16
+ toJSON(): Record<string, any> {
17
+ return {
18
+ error: this.errorCode,
19
+ error_description: this.message,
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,159 @@
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 { AuthCode } from '../models/AuthCode.ts'
7
+ import { AccessToken } from '../models/AccessToken.ts'
8
+ import { RefreshToken } from '../models/RefreshToken.ts'
9
+ import { OAuthError } from '../errors/OAuthError.ts'
10
+
11
+ /**
12
+ * Authorization Code grant with PKCE support.
13
+ */
14
+ export class AuthCodeGrant implements GrantHandler {
15
+ readonly grantType = 'authorization_code'
16
+
17
+ constructor(
18
+ private readonly signer: JwtSigner,
19
+ private readonly server: OAuthServer,
20
+ ) {}
21
+
22
+ async handle(request: MantiqRequest): Promise<OAuthTokenResponse> {
23
+ const code = await request.input('code') as string | undefined
24
+ const redirectUri = await request.input('redirect_uri') as string | undefined
25
+ const clientId = await request.input('client_id') as string | undefined
26
+ const clientSecret = await request.input('client_secret') as string | undefined
27
+ const codeVerifier = await request.input('code_verifier') as string | undefined
28
+
29
+ if (!code) throw new OAuthError('The code parameter is required.', 'invalid_request')
30
+ if (!clientId) throw new OAuthError('The client_id parameter is required.', 'invalid_request')
31
+ if (!redirectUri) throw new OAuthError('The redirect_uri parameter is required.', 'invalid_request')
32
+
33
+ // Resolve client
34
+ const client = await Client.find(clientId)
35
+ if (!client) throw new OAuthError('Client not found.', 'invalid_client', 401)
36
+
37
+ // Verify client secret for confidential clients
38
+ if (client.confidential()) {
39
+ if (!clientSecret || clientSecret !== client.getAttribute('secret')) {
40
+ throw new OAuthError('Invalid client credentials.', 'invalid_client', 401)
41
+ }
42
+ }
43
+
44
+ // Resolve auth code
45
+ const authCode = await AuthCode.where('id', code)
46
+ .where('revoked', false)
47
+ .first() as AuthCode | null
48
+
49
+ if (!authCode) throw new OAuthError('Invalid authorization code.', 'invalid_grant')
50
+
51
+ // Verify the code belongs to this client
52
+ if (authCode.getAttribute('client_id') !== clientId) {
53
+ throw new OAuthError('Authorization code does not belong to this client.', 'invalid_grant')
54
+ }
55
+
56
+ // Check expiration
57
+ const expiresAt = authCode.getAttribute('expires_at')
58
+ if (expiresAt && new Date(expiresAt) < new Date()) {
59
+ throw new OAuthError('Authorization code has expired.', 'invalid_grant')
60
+ }
61
+
62
+ // Verify PKCE code_challenge
63
+ const storedChallenge = authCode.getAttribute('code_challenge') as string | null
64
+ const challengeMethod = (authCode.getAttribute('code_challenge_method') as string) || 'plain'
65
+
66
+ if (storedChallenge) {
67
+ if (!codeVerifier) {
68
+ throw new OAuthError('The code_verifier parameter is required.', 'invalid_request')
69
+ }
70
+ const isValid = await this.verifyCodeChallenge(codeVerifier, storedChallenge, challengeMethod)
71
+ if (!isValid) {
72
+ throw new OAuthError('Invalid code verifier.', 'invalid_grant')
73
+ }
74
+ }
75
+
76
+ // Revoke the auth code (single use)
77
+ authCode.setAttribute('revoked', true)
78
+ await authCode.save()
79
+
80
+ // Issue tokens
81
+ const userId = authCode.getAttribute('user_id') as string
82
+ const scopes = (authCode.getAttribute('scopes') as string[]) || []
83
+ const tokenId = crypto.randomUUID()
84
+ const now = Math.floor(Date.now() / 1000)
85
+
86
+ // Create access token record
87
+ await AccessToken.create({
88
+ id: tokenId,
89
+ user_id: userId,
90
+ client_id: clientId,
91
+ name: null,
92
+ scopes: JSON.stringify(scopes),
93
+ revoked: false,
94
+ expires_at: new Date((now + this.server.tokenLifetime) * 1000).toISOString(),
95
+ })
96
+
97
+ // Create refresh token
98
+ const refreshTokenId = crypto.randomUUID()
99
+ await RefreshToken.create({
100
+ id: refreshTokenId,
101
+ access_token_id: tokenId,
102
+ revoked: false,
103
+ expires_at: new Date((now + this.server.refreshTokenLifetime) * 1000).toISOString(),
104
+ })
105
+
106
+ // Sign JWT
107
+ const jwt = await this.signer.sign({
108
+ iss: 'mantiq-oauth',
109
+ sub: userId,
110
+ aud: clientId,
111
+ exp: now + this.server.tokenLifetime,
112
+ iat: now,
113
+ jti: tokenId,
114
+ scopes,
115
+ })
116
+
117
+ return {
118
+ token_type: 'Bearer',
119
+ expires_in: this.server.tokenLifetime,
120
+ access_token: jwt,
121
+ refresh_token: refreshTokenId,
122
+ scope: scopes.join(' '),
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Verify the PKCE code challenge.
128
+ */
129
+ private async verifyCodeChallenge(
130
+ codeVerifier: string,
131
+ codeChallenge: string,
132
+ method: string,
133
+ ): Promise<boolean> {
134
+ if (method === 'plain') {
135
+ return codeVerifier === codeChallenge
136
+ }
137
+
138
+ if (method === 'S256') {
139
+ const encoder = new TextEncoder()
140
+ const data = encoder.encode(codeVerifier)
141
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data)
142
+ const hashArray = new Uint8Array(hashBuffer)
143
+
144
+ // Base64URL encode the hash
145
+ let binary = ''
146
+ for (let i = 0; i < hashArray.length; i++) {
147
+ binary += String.fromCharCode(hashArray[i]!)
148
+ }
149
+ const base64url = btoa(binary)
150
+ .replace(/\+/g, '-')
151
+ .replace(/\//g, '_')
152
+ .replace(/=+$/, '')
153
+
154
+ return base64url === codeChallenge
155
+ }
156
+
157
+ throw new OAuthError(`Unsupported code challenge method: ${method}`, 'invalid_request')
158
+ }
159
+ }