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