@stravigor/oauth2 0.4.5
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/README.md +72 -0
- package/package.json +27 -0
- package/src/actions.ts +20 -0
- package/src/auth_code.ts +154 -0
- package/src/client.ts +155 -0
- package/src/commands/oauth2_commands.ts +152 -0
- package/src/errors.ts +62 -0
- package/src/handlers/authorize.ts +243 -0
- package/src/handlers/clients.ts +101 -0
- package/src/handlers/introspect.ts +61 -0
- package/src/handlers/personal_tokens.ts +115 -0
- package/src/handlers/revoke.ts +71 -0
- package/src/handlers/token.ts +290 -0
- package/src/helpers.ts +98 -0
- package/src/index.ts +59 -0
- package/src/middleware/oauth.ts +61 -0
- package/src/middleware/scopes.ts +37 -0
- package/src/oauth2_manager.ts +217 -0
- package/src/oauth2_provider.ts +29 -0
- package/src/scopes.ts +77 -0
- package/src/token.ts +231 -0
- package/src/types.ts +137 -0
- package/src/utils.ts +15 -0
- package/stubs/actions/oauth2.ts +28 -0
- package/stubs/config/oauth2.ts +35 -0
- package/stubs/schemas/oauth_auth_code.ts +16 -0
- package/stubs/schemas/oauth_client.ts +15 -0
- package/stubs/schemas/oauth_token.ts +17 -0
- package/tsconfig.json +4 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# @stravigor/oauth2
|
|
2
|
+
|
|
3
|
+
OAuth2 server for the [Strav](https://www.npmjs.com/package/@stravigor/core) framework. Authorization Code + PKCE, Client Credentials, Refresh Token rotation, Token Revocation (RFC 7009), Token Introspection (RFC 7662), personal access tokens, and scoped API access.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @stravigor/oauth2
|
|
9
|
+
bun strav package:install oauth2
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Requires `@stravigor/core` as a peer dependency.
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { defineActions } from '@stravigor/oauth2'
|
|
18
|
+
import User from './models/user'
|
|
19
|
+
|
|
20
|
+
const actions = defineActions<User>({
|
|
21
|
+
async findById(id) { return User.find(id) },
|
|
22
|
+
identifierOf(user) { return user.email },
|
|
23
|
+
})
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { OAuth2Provider } from '@stravigor/oauth2'
|
|
28
|
+
|
|
29
|
+
app.use(new OAuth2Provider(actions))
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
bun strav oauth2:setup # Create tables + personal access client
|
|
34
|
+
bun strav oauth2:client --name "My App" --redirect "https://app.com/callback"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Middleware
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { oauth, scopes } from '@stravigor/oauth2'
|
|
41
|
+
import { compose } from '@stravigor/core/http/middleware'
|
|
42
|
+
|
|
43
|
+
router.group({ prefix: '/api', middleware: [oauth()] }, r => {
|
|
44
|
+
r.get('/user', ctx => ctx.json({ user: ctx.get('user') }))
|
|
45
|
+
r.get('/repos', compose([scopes('repos:read')], listRepos))
|
|
46
|
+
r.post('/repos', compose([scopes('repos:write')], createRepo))
|
|
47
|
+
})
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Personal Access Tokens
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { oauth2 } from '@stravigor/oauth2'
|
|
54
|
+
|
|
55
|
+
const { token } = await oauth2.createPersonalToken(user, 'CLI Tool', ['read', 'write'])
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## CLI
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
bun strav oauth2:setup # Create tables and personal access client
|
|
62
|
+
bun strav oauth2:client # Create a new OAuth2 client
|
|
63
|
+
bun strav oauth2:purge # Clean up expired tokens and codes
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Documentation
|
|
67
|
+
|
|
68
|
+
See the full [OAuth2 guide](../../guides/oauth2.md).
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stravigor/oauth2",
|
|
3
|
+
"version": "0.4.5",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "OAuth2 server implementation for the Strav framework",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./*": "./src/*.ts"
|
|
10
|
+
},
|
|
11
|
+
"strav": {
|
|
12
|
+
"commands": "src/commands"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src/",
|
|
16
|
+
"stubs/",
|
|
17
|
+
"package.json",
|
|
18
|
+
"tsconfig.json"
|
|
19
|
+
],
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@stravigor/core": "0.4.4"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "bun test tests/",
|
|
25
|
+
"typecheck": "tsc --noEmit"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/actions.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { OAuth2Actions } from './types.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Type-safe identity function for defining OAuth2 actions.
|
|
5
|
+
* Zero runtime cost — just provides autocompletion and type checking.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* import { defineActions } from '@stravigor/oauth2'
|
|
9
|
+
* import { User } from '../models/user'
|
|
10
|
+
*
|
|
11
|
+
* export default defineActions<User>({
|
|
12
|
+
* findById: (id) => User.find(id),
|
|
13
|
+
* identifierOf: (user) => user.email,
|
|
14
|
+
* })
|
|
15
|
+
*/
|
|
16
|
+
export function defineActions<TUser = unknown>(
|
|
17
|
+
actions: OAuth2Actions<TUser>
|
|
18
|
+
): OAuth2Actions<TUser> {
|
|
19
|
+
return actions
|
|
20
|
+
}
|
package/src/auth_code.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import OAuth2Manager from './oauth2_manager.ts'
|
|
2
|
+
import { randomHex } from '@stravigor/core/helpers'
|
|
3
|
+
import type { OAuthAuthCodeData } from './types.ts'
|
|
4
|
+
|
|
5
|
+
function hashCode(plain: string): string {
|
|
6
|
+
return new Bun.CryptoHasher('sha256').update(plain).digest('hex')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Static helper for managing OAuth2 authorization codes.
|
|
11
|
+
*
|
|
12
|
+
* Codes are SHA-256 hashed before storage and are single-use.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* const { code, codeData } = await AuthCode.create({ ... })
|
|
16
|
+
* const record = await AuthCode.consume(plainCode, clientId, redirectUri, codeVerifier)
|
|
17
|
+
*/
|
|
18
|
+
export default class AuthCode {
|
|
19
|
+
/**
|
|
20
|
+
* Create a new authorization code.
|
|
21
|
+
* Returns the plain-text code (sent to client via redirect) and the DB record.
|
|
22
|
+
*/
|
|
23
|
+
static async create(params: {
|
|
24
|
+
clientId: string
|
|
25
|
+
userId: string
|
|
26
|
+
redirectUri: string
|
|
27
|
+
scopes: string[]
|
|
28
|
+
codeChallenge?: string | null
|
|
29
|
+
codeChallengeMethod?: string | null
|
|
30
|
+
}): Promise<{ code: string; codeData: OAuthAuthCodeData }> {
|
|
31
|
+
const config = OAuth2Manager.config
|
|
32
|
+
const plainCode = randomHex(40)
|
|
33
|
+
const hashedCode = hashCode(plainCode)
|
|
34
|
+
const expiresAt = new Date(Date.now() + config.authCodeLifetime * 60_000)
|
|
35
|
+
|
|
36
|
+
const rows = await OAuth2Manager.db.sql`
|
|
37
|
+
INSERT INTO "_strav_oauth_auth_codes" (
|
|
38
|
+
"client_id", "user_id", "code", "redirect_uri", "scopes",
|
|
39
|
+
"code_challenge", "code_challenge_method", "expires_at"
|
|
40
|
+
)
|
|
41
|
+
VALUES (
|
|
42
|
+
${params.clientId},
|
|
43
|
+
${params.userId},
|
|
44
|
+
${hashedCode},
|
|
45
|
+
${params.redirectUri},
|
|
46
|
+
${JSON.stringify(params.scopes)},
|
|
47
|
+
${params.codeChallenge ?? null},
|
|
48
|
+
${params.codeChallengeMethod ?? null},
|
|
49
|
+
${expiresAt}
|
|
50
|
+
)
|
|
51
|
+
RETURNING *
|
|
52
|
+
`
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
code: plainCode,
|
|
56
|
+
codeData: AuthCode.hydrate(rows[0] as Record<string, unknown>),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Consume an authorization code. Validates and marks it as used.
|
|
62
|
+
*
|
|
63
|
+
* Checks:
|
|
64
|
+
* - Code exists and belongs to the client
|
|
65
|
+
* - Code is not expired
|
|
66
|
+
* - Code has not been used before
|
|
67
|
+
* - Redirect URI matches
|
|
68
|
+
* - PKCE code_verifier matches (if code_challenge was set)
|
|
69
|
+
*
|
|
70
|
+
* Returns the code data if valid, null otherwise.
|
|
71
|
+
*/
|
|
72
|
+
static async consume(
|
|
73
|
+
plainCode: string,
|
|
74
|
+
clientId: string,
|
|
75
|
+
redirectUri: string,
|
|
76
|
+
codeVerifier?: string | null
|
|
77
|
+
): Promise<OAuthAuthCodeData | null> {
|
|
78
|
+
const hash = hashCode(plainCode)
|
|
79
|
+
|
|
80
|
+
const rows = await OAuth2Manager.db.sql`
|
|
81
|
+
SELECT * FROM "_strav_oauth_auth_codes"
|
|
82
|
+
WHERE "code" = ${hash} AND "client_id" = ${clientId}
|
|
83
|
+
LIMIT 1
|
|
84
|
+
`
|
|
85
|
+
if (rows.length === 0) return null
|
|
86
|
+
|
|
87
|
+
const record = AuthCode.hydrate(rows[0] as Record<string, unknown>)
|
|
88
|
+
|
|
89
|
+
// Already used — potential replay attack
|
|
90
|
+
if (record.usedAt) return null
|
|
91
|
+
|
|
92
|
+
// Expired
|
|
93
|
+
if (record.expiresAt.getTime() < Date.now()) return null
|
|
94
|
+
|
|
95
|
+
// Redirect URI mismatch
|
|
96
|
+
if (record.redirectUri !== redirectUri) return null
|
|
97
|
+
|
|
98
|
+
// PKCE verification
|
|
99
|
+
if (record.codeChallenge) {
|
|
100
|
+
if (!codeVerifier) return null
|
|
101
|
+
|
|
102
|
+
if (record.codeChallengeMethod === 'S256') {
|
|
103
|
+
const verifierHash = new Bun.CryptoHasher('sha256').update(codeVerifier).digest('base64url')
|
|
104
|
+
if (verifierHash !== record.codeChallenge) return null
|
|
105
|
+
} else {
|
|
106
|
+
// plain method
|
|
107
|
+
if (codeVerifier !== record.codeChallenge) return null
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Mark as used
|
|
112
|
+
await OAuth2Manager.db.sql`
|
|
113
|
+
UPDATE "_strav_oauth_auth_codes"
|
|
114
|
+
SET "used_at" = NOW()
|
|
115
|
+
WHERE "id" = ${record.id}
|
|
116
|
+
`
|
|
117
|
+
|
|
118
|
+
return record
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Prune expired and used auth codes. Returns the number of deleted rows. */
|
|
122
|
+
static async prune(): Promise<number> {
|
|
123
|
+
const result = await OAuth2Manager.db.sql`
|
|
124
|
+
DELETE FROM "_strav_oauth_auth_codes"
|
|
125
|
+
WHERE "expires_at" < NOW() OR "used_at" IS NOT NULL
|
|
126
|
+
`
|
|
127
|
+
return result.count ?? 0
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Internal
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
static hydrate(row: Record<string, unknown>): OAuthAuthCodeData {
|
|
135
|
+
return {
|
|
136
|
+
id: String(row.id),
|
|
137
|
+
clientId: String(row.client_id),
|
|
138
|
+
userId: row.user_id as string,
|
|
139
|
+
redirectUri: row.redirect_uri as string,
|
|
140
|
+
scopes: parseJsonb(row.scopes) as string[],
|
|
141
|
+
codeChallenge: (row.code_challenge as string) ?? null,
|
|
142
|
+
codeChallengeMethod: (row.code_challenge_method as string) ?? null,
|
|
143
|
+
expiresAt: row.expires_at as Date,
|
|
144
|
+
usedAt: (row.used_at as Date) ?? null,
|
|
145
|
+
createdAt: row.created_at as Date,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parseJsonb(value: unknown): unknown {
|
|
151
|
+
if (value === null || value === undefined) return []
|
|
152
|
+
if (typeof value === 'string') return JSON.parse(value)
|
|
153
|
+
return value
|
|
154
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { timingSafeEqual as nodeTimingSafeEqual } from 'node:crypto'
|
|
2
|
+
import OAuth2Manager from './oauth2_manager.ts'
|
|
3
|
+
import { randomHex } from '@stravigor/core/helpers'
|
|
4
|
+
import type { OAuthClientData, CreateClientInput, GrantType } from './types.ts'
|
|
5
|
+
|
|
6
|
+
function hashSecret(plain: string): string {
|
|
7
|
+
return new Bun.CryptoHasher('sha256').update(plain).digest('hex')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Static helper for managing OAuth2 clients.
|
|
12
|
+
*
|
|
13
|
+
* Client secrets are SHA-256 hashed before storage — the plain-text
|
|
14
|
+
* secret is returned exactly once at creation time.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* const { client, plainSecret } = await OAuthClient.create({ name: 'Mobile App', redirectUris: ['myapp://callback'] })
|
|
18
|
+
* const client = await OAuthClient.find(id)
|
|
19
|
+
* await OAuthClient.revoke(id)
|
|
20
|
+
*/
|
|
21
|
+
export default class OAuthClient {
|
|
22
|
+
/** Create a new OAuth client. Returns the client record and the plain secret (if confidential). */
|
|
23
|
+
static async create(input: CreateClientInput): Promise<{
|
|
24
|
+
client: OAuthClientData
|
|
25
|
+
plainSecret: string | null
|
|
26
|
+
}> {
|
|
27
|
+
const confidential = input.confidential ?? true
|
|
28
|
+
const firstParty = input.firstParty ?? false
|
|
29
|
+
const grantTypes = input.grantTypes ?? ['authorization_code', 'refresh_token']
|
|
30
|
+
|
|
31
|
+
let plainSecret: string | null = null
|
|
32
|
+
let hashedSecret: string | null = null
|
|
33
|
+
|
|
34
|
+
if (confidential) {
|
|
35
|
+
plainSecret = randomHex(32)
|
|
36
|
+
hashedSecret = hashSecret(plainSecret)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const rows = await OAuth2Manager.db.sql`
|
|
40
|
+
INSERT INTO "_strav_oauth_clients" (
|
|
41
|
+
"name", "secret", "redirect_uris", "scopes", "grant_types",
|
|
42
|
+
"confidential", "first_party", "revoked"
|
|
43
|
+
)
|
|
44
|
+
VALUES (
|
|
45
|
+
${input.name},
|
|
46
|
+
${hashedSecret},
|
|
47
|
+
${JSON.stringify(input.redirectUris)},
|
|
48
|
+
${input.scopes !== undefined ? JSON.stringify(input.scopes) : null},
|
|
49
|
+
${JSON.stringify(grantTypes)},
|
|
50
|
+
${confidential},
|
|
51
|
+
${firstParty},
|
|
52
|
+
${false}
|
|
53
|
+
)
|
|
54
|
+
RETURNING *
|
|
55
|
+
`
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
client: OAuthClient.hydrate(rows[0] as Record<string, unknown>),
|
|
59
|
+
plainSecret,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Find a client by ID. Returns null if not found or revoked. */
|
|
64
|
+
static async find(id: string): Promise<OAuthClientData | null> {
|
|
65
|
+
const rows = await OAuth2Manager.db.sql`
|
|
66
|
+
SELECT * FROM "_strav_oauth_clients" WHERE "id" = ${id} LIMIT 1
|
|
67
|
+
`
|
|
68
|
+
if (rows.length === 0) return null
|
|
69
|
+
return OAuthClient.hydrate(rows[0] as Record<string, unknown>)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Find a client by ID, including revoked clients. */
|
|
73
|
+
static async findIncludingRevoked(id: string): Promise<OAuthClientData | null> {
|
|
74
|
+
const rows = await OAuth2Manager.db.sql`
|
|
75
|
+
SELECT * FROM "_strav_oauth_clients" WHERE "id" = ${id} LIMIT 1
|
|
76
|
+
`
|
|
77
|
+
if (rows.length === 0) return null
|
|
78
|
+
return OAuthClient.hydrate(rows[0] as Record<string, unknown>)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Verify a plain-text client secret against the stored hash. */
|
|
82
|
+
static async verifySecret(client: OAuthClientData, plainSecret: string): Promise<boolean> {
|
|
83
|
+
const rows = await OAuth2Manager.db.sql`
|
|
84
|
+
SELECT "secret" FROM "_strav_oauth_clients" WHERE "id" = ${client.id} LIMIT 1
|
|
85
|
+
`
|
|
86
|
+
if (rows.length === 0) return false
|
|
87
|
+
const stored = (rows[0] as Record<string, unknown>).secret as string | null
|
|
88
|
+
if (!stored) return false
|
|
89
|
+
|
|
90
|
+
const hash = hashSecret(plainSecret)
|
|
91
|
+
return timingSafeEqual(stored, hash)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** List all non-revoked clients. */
|
|
95
|
+
static async all(): Promise<OAuthClientData[]> {
|
|
96
|
+
const rows = await OAuth2Manager.db.sql`
|
|
97
|
+
SELECT * FROM "_strav_oauth_clients" WHERE "revoked" = false ORDER BY "created_at" DESC
|
|
98
|
+
`
|
|
99
|
+
return (rows as Record<string, unknown>[]).map(OAuthClient.hydrate)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** List all clients belonging to a user (clients they created). */
|
|
103
|
+
static async allForUser(userId: string): Promise<OAuthClientData[]> {
|
|
104
|
+
// All non-revoked clients visible to the user
|
|
105
|
+
return OAuthClient.all()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Soft-revoke a client. */
|
|
109
|
+
static async revoke(id: string): Promise<void> {
|
|
110
|
+
await OAuth2Manager.db.sql`
|
|
111
|
+
UPDATE "_strav_oauth_clients" SET "revoked" = true, "updated_at" = NOW()
|
|
112
|
+
WHERE "id" = ${id}
|
|
113
|
+
`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Hard-delete a client and all its tokens/codes. */
|
|
117
|
+
static async destroy(id: string): Promise<void> {
|
|
118
|
+
const db = OAuth2Manager.db
|
|
119
|
+
await db.sql`DELETE FROM "_strav_oauth_auth_codes" WHERE "client_id" = ${id}`
|
|
120
|
+
await db.sql`DELETE FROM "_strav_oauth_tokens" WHERE "client_id" = ${id}`
|
|
121
|
+
await db.sql`DELETE FROM "_strav_oauth_clients" WHERE "id" = ${id}`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Internal
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
static hydrate(row: Record<string, unknown>): OAuthClientData {
|
|
129
|
+
return {
|
|
130
|
+
id: String(row.id),
|
|
131
|
+
name: row.name as string,
|
|
132
|
+
redirectUris: parseJsonb(row.redirect_uris) as string[],
|
|
133
|
+
scopes: parseJsonb(row.scopes) as string[] | null,
|
|
134
|
+
grantTypes: parseJsonb(row.grant_types) as GrantType[],
|
|
135
|
+
confidential: row.confidential as boolean,
|
|
136
|
+
firstParty: row.first_party as boolean,
|
|
137
|
+
revoked: row.revoked as boolean,
|
|
138
|
+
createdAt: row.created_at as Date,
|
|
139
|
+
updatedAt: row.updated_at as Date,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Timing-safe string comparison. */
|
|
145
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
146
|
+
if (a.length !== b.length) return false
|
|
147
|
+
return nodeTimingSafeEqual(Buffer.from(a), Buffer.from(b))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Parse a JSONB column that may already be an object or a string. */
|
|
151
|
+
function parseJsonb(value: unknown): unknown {
|
|
152
|
+
if (value === null || value === undefined) return null
|
|
153
|
+
if (typeof value === 'string') return JSON.parse(value)
|
|
154
|
+
return value
|
|
155
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { Command } from 'commander'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import { bootstrap, shutdown } from '@stravigor/core/cli/bootstrap'
|
|
4
|
+
import OAuth2Manager from '../oauth2_manager.ts'
|
|
5
|
+
import OAuthClient from '../client.ts'
|
|
6
|
+
import OAuthToken from '../token.ts'
|
|
7
|
+
import AuthCode from '../auth_code.ts'
|
|
8
|
+
|
|
9
|
+
export function register(program: Command): void {
|
|
10
|
+
// ── oauth2:setup ────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.command('oauth2:setup')
|
|
14
|
+
.description('Create OAuth2 tables and a default personal access client')
|
|
15
|
+
.action(async () => {
|
|
16
|
+
let db
|
|
17
|
+
try {
|
|
18
|
+
const { db: database, config } = await bootstrap()
|
|
19
|
+
db = database
|
|
20
|
+
|
|
21
|
+
new OAuth2Manager(db, config)
|
|
22
|
+
|
|
23
|
+
console.log(chalk.dim('Creating OAuth2 tables...'))
|
|
24
|
+
await OAuth2Manager.ensureTables()
|
|
25
|
+
console.log(chalk.green('OAuth2 tables created successfully.'))
|
|
26
|
+
|
|
27
|
+
// Create personal access client if not already configured
|
|
28
|
+
const patClientId = OAuth2Manager.config.personalAccessClient
|
|
29
|
+
if (!patClientId) {
|
|
30
|
+
console.log(chalk.dim('Creating personal access client...'))
|
|
31
|
+
const { client } = await OAuthClient.create({
|
|
32
|
+
name: 'Personal Access Client',
|
|
33
|
+
redirectUris: [],
|
|
34
|
+
confidential: true,
|
|
35
|
+
firstParty: true,
|
|
36
|
+
grantTypes: [],
|
|
37
|
+
})
|
|
38
|
+
console.log(chalk.green(`Personal access client created: ${chalk.bold(client.id)}`))
|
|
39
|
+
console.log(
|
|
40
|
+
chalk.yellow(
|
|
41
|
+
`\nAdd this to your config/oauth2.ts:\n personalAccessClient: '${client.id}'`
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
} else {
|
|
45
|
+
console.log(chalk.dim(`Personal access client already configured: ${patClientId}`))
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
|
|
49
|
+
process.exit(1)
|
|
50
|
+
} finally {
|
|
51
|
+
if (db) await shutdown(db)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// ── oauth2:client ───────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
program
|
|
58
|
+
.command('oauth2:client')
|
|
59
|
+
.description('Create a new OAuth2 client')
|
|
60
|
+
.requiredOption('--name <name>', 'Client name')
|
|
61
|
+
.option('--redirect <uris...>', 'Redirect URIs', [])
|
|
62
|
+
.option('--public', 'Create a public (non-confidential) client', false)
|
|
63
|
+
.option('--first-party', 'Mark as a first-party (trusted) client', false)
|
|
64
|
+
.option('--credentials', 'Enable client_credentials grant', false)
|
|
65
|
+
.action(
|
|
66
|
+
async (options: {
|
|
67
|
+
name: string
|
|
68
|
+
redirect: string[]
|
|
69
|
+
public: boolean
|
|
70
|
+
firstParty: boolean
|
|
71
|
+
credentials: boolean
|
|
72
|
+
}) => {
|
|
73
|
+
let db
|
|
74
|
+
try {
|
|
75
|
+
const { db: database, config } = await bootstrap()
|
|
76
|
+
db = database
|
|
77
|
+
|
|
78
|
+
new OAuth2Manager(db, config)
|
|
79
|
+
await OAuth2Manager.ensureTables()
|
|
80
|
+
|
|
81
|
+
const grantTypes = ['authorization_code', 'refresh_token'] as (
|
|
82
|
+
| 'authorization_code'
|
|
83
|
+
| 'client_credentials'
|
|
84
|
+
| 'refresh_token'
|
|
85
|
+
)[]
|
|
86
|
+
if (options.credentials) {
|
|
87
|
+
grantTypes.push('client_credentials')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const { client, plainSecret } = await OAuthClient.create({
|
|
91
|
+
name: options.name,
|
|
92
|
+
redirectUris: options.redirect,
|
|
93
|
+
confidential: !options.public,
|
|
94
|
+
firstParty: options.firstParty,
|
|
95
|
+
grantTypes,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
console.log(chalk.green('\nOAuth2 client created successfully.\n'))
|
|
99
|
+
console.log(` ${chalk.dim('Client ID:')} ${chalk.bold(client.id)}`)
|
|
100
|
+
|
|
101
|
+
if (plainSecret) {
|
|
102
|
+
console.log(` ${chalk.dim('Client Secret:')} ${chalk.bold(plainSecret)}`)
|
|
103
|
+
console.log(chalk.yellow('\n Store the secret securely — it will not be shown again.'))
|
|
104
|
+
} else {
|
|
105
|
+
console.log(` ${chalk.dim('Type:')} Public (no secret)`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
console.log(
|
|
109
|
+
` ${chalk.dim('Redirect URIs:')} ${client.redirectUris.join(', ') || '(none)'}`
|
|
110
|
+
)
|
|
111
|
+
console.log(` ${chalk.dim('Grant Types:')} ${client.grantTypes.join(', ')}`)
|
|
112
|
+
console.log(` ${chalk.dim('First Party:')} ${client.firstParty}`)
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
|
|
115
|
+
process.exit(1)
|
|
116
|
+
} finally {
|
|
117
|
+
if (db) await shutdown(db)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
// ── oauth2:purge ────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
program
|
|
125
|
+
.command('oauth2:purge')
|
|
126
|
+
.description('Purge expired tokens and used authorization codes')
|
|
127
|
+
.option('--days <days>', 'Delete revoked tokens older than N days', '7')
|
|
128
|
+
.action(async (options: { days: string }) => {
|
|
129
|
+
let db
|
|
130
|
+
try {
|
|
131
|
+
const { db: database, config } = await bootstrap()
|
|
132
|
+
db = database
|
|
133
|
+
|
|
134
|
+
new OAuth2Manager(db, config)
|
|
135
|
+
|
|
136
|
+
const days = parseInt(options.days, 10)
|
|
137
|
+
|
|
138
|
+
console.log(chalk.dim('Purging expired tokens...'))
|
|
139
|
+
const tokenCount = await OAuthToken.prune(days)
|
|
140
|
+
console.log(chalk.green(` ${tokenCount} token(s) pruned.`))
|
|
141
|
+
|
|
142
|
+
console.log(chalk.dim('Purging used/expired authorization codes...'))
|
|
143
|
+
const codeCount = await AuthCode.prune()
|
|
144
|
+
console.log(chalk.green(` ${codeCount} authorization code(s) pruned.`))
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
|
|
147
|
+
process.exit(1)
|
|
148
|
+
} finally {
|
|
149
|
+
if (db) await shutdown(db)
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { StravError } from '@stravigor/core/exceptions/strav_error'
|
|
2
|
+
|
|
3
|
+
/** Base error for all OAuth2 errors. */
|
|
4
|
+
export class OAuth2Error extends StravError {
|
|
5
|
+
constructor(
|
|
6
|
+
message: string,
|
|
7
|
+
public readonly errorCode: string = 'server_error',
|
|
8
|
+
public readonly statusCode: number = 400
|
|
9
|
+
) {
|
|
10
|
+
super(message)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Build a JSON response matching RFC 6749 error format. */
|
|
14
|
+
toJSON(): Record<string, string> {
|
|
15
|
+
return {
|
|
16
|
+
error: this.errorCode,
|
|
17
|
+
error_description: this.message,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The authorization grant type is not supported. */
|
|
23
|
+
export class UnsupportedGrantError extends OAuth2Error {
|
|
24
|
+
constructor() {
|
|
25
|
+
super('The authorization grant type is not supported.', 'unsupported_grant_type')
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Client authentication failed. */
|
|
30
|
+
export class InvalidClientError extends OAuth2Error {
|
|
31
|
+
constructor(message = 'Client authentication failed.') {
|
|
32
|
+
super(message, 'invalid_client', 401)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The provided authorization grant is invalid or expired. */
|
|
37
|
+
export class InvalidGrantError extends OAuth2Error {
|
|
38
|
+
constructor(message = 'The provided authorization grant is invalid, expired, or revoked.') {
|
|
39
|
+
super(message, 'invalid_grant')
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The request is missing a required parameter or is malformed. */
|
|
44
|
+
export class InvalidRequestError extends OAuth2Error {
|
|
45
|
+
constructor(message = 'The request is missing a required parameter.') {
|
|
46
|
+
super(message, 'invalid_request')
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The requested scope is invalid or unknown. */
|
|
51
|
+
export class InvalidScopeError extends OAuth2Error {
|
|
52
|
+
constructor(message = 'The requested scope is invalid or unknown.') {
|
|
53
|
+
super(message, 'invalid_scope')
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The resource owner denied the authorization request. */
|
|
58
|
+
export class AccessDeniedError extends OAuth2Error {
|
|
59
|
+
constructor(message = 'The resource owner denied the request.') {
|
|
60
|
+
super(message, 'access_denied', 403)
|
|
61
|
+
}
|
|
62
|
+
}
|