Skip to content

Commit 9ee4996

Browse files
zrosenbauerclaude
andcommitted
feat(packages/core)!: replace oauth with PKCE and add device code flow
Replace the non-standard direct-token-POST OAuth flow with a spec-compliant Authorization Code + PKCE implementation (RFC 7636 + RFC 8252). Add a new device-code resolver implementing the Device Authorization Grant (RFC 8628) for headless environments. BREAKING CHANGE: OAuthSourceConfig now requires clientId and tokenUrl fields. The previous oauth resolver that accepted a direct token POST has been removed. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 6c5e60a commit 9ee4996

25 files changed

+3893
-456
lines changed

.changeset/config.json

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,5 @@
77
"access": "public",
88
"baseBranch": "main",
99
"updateInternalDependencies": "patch",
10-
"ignore": [
11-
"@examples/simple",
12-
"@examples/advanced",
13-
"@examples/authenticated-service"
14-
]
10+
"ignore": ["@examples/simple", "@examples/advanced", "@examples/authenticated-service"]
1511
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@kidd-cli/core": minor
3+
---
4+
5+
Replace non-standard OAuth flow with spec-compliant PKCE (RFC 7636) and add Device Authorization Grant (RFC 8628)
6+
7+
The `oauth` resolver now implements the standard OAuth 2.0 Authorization Code flow with PKCE. The previous non-standard direct-token-POST flow has been removed entirely.
8+
9+
**Breaking change:** `OAuthSourceConfig` now requires `clientId` and `tokenUrl` in addition to `authUrl`.
10+
11+
Before:
12+
```ts
13+
{ source: 'oauth', authUrl: 'https://example.com/auth' }
14+
```
15+
16+
After:
17+
```ts
18+
{ source: 'oauth', clientId: 'my-client-id', authUrl: 'https://example.com/authorize', tokenUrl: 'https://example.com/token' }
19+
```
20+
21+
New `device-code` resolver added for headless/browserless environments (RFC 8628).

docs/concepts/authentication.md

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -112,22 +112,73 @@ Reads any credential type from a JSON file on disk via kidd's store system.
112112
| `filename` | `string` | `'auth.json'` | Filename within the store dir |
113113
| `dirName` | `string` | `.<cli-name>` | Store directory name |
114114

115-
### `oauth` -- OAuth Browser Flow
115+
### `oauth` -- OAuth Authorization Code + PKCE (RFC 7636)
116116

117-
Opens the user's browser to an auth URL, starts a local HTTP server to receive the callback, and extracts the token from a POST request with a JSON body `{ "token": "<value>" }`. Query-string tokens are not accepted to prevent credential leakage through browser history, server logs, and referrer headers.
117+
Implements the standard OAuth 2.0 Authorization Code flow with Proof Key for Code Exchange (PKCE) per [RFC 7636](https://tools.ietf.org/html/rfc7636) and [RFC 8252](https://tools.ietf.org/html/rfc8252) for native apps.
118+
119+
The flow:
120+
121+
1. CLI generates a `code_verifier` and derives the `code_challenge` (S256)
122+
2. CLI starts a local HTTP server on `127.0.0.1` and opens the browser to the authorization URL
123+
3. User authenticates in the browser; the authorization server redirects back to the local server with an authorization code via GET
124+
4. CLI exchanges the code at the token endpoint with the `code_verifier`
125+
5. Token endpoint validates the verifier and returns an access token
126+
127+
```ts
128+
{
129+
source: 'oauth',
130+
clientId: 'my-client-id',
131+
authUrl: 'https://example.com/authorize',
132+
tokenUrl: 'https://example.com/token',
133+
scopes: ['openid', 'profile'],
134+
}
135+
```
136+
137+
| Option | Type | Default | Description |
138+
| -------------- | ------------------- | ------------- | --------------------------------- |
139+
| `clientId` | `string` | -- | OAuth client ID (required) |
140+
| `authUrl` | `string` | -- | Authorization endpoint (required) |
141+
| `tokenUrl` | `string` | -- | Token endpoint (required) |
142+
| `scopes` | `readonly string[]` | `[]` | OAuth scopes to request |
143+
| `port` | `number` | `0` (random) | Local callback server port |
144+
| `callbackPath` | `string` | `'/callback'` | Callback endpoint path |
145+
| `timeout` | `number` | `120_000` | Timeout in milliseconds |
146+
147+
Compatible with any OAuth 2.0 provider that supports PKCE with public clients, including Clerk (configured as a public OAuth application).
148+
149+
### `device-code` -- Device Authorization Grant (RFC 8628)
150+
151+
Implements the [OAuth 2.0 Device Authorization Grant](https://tools.ietf.org/html/rfc8628) for headless or browserless environments.
152+
153+
The flow:
154+
155+
1. CLI requests a device code from the authorization server
156+
2. CLI displays a verification URL and user code for the user to enter in a browser
157+
3. CLI polls the token endpoint until the user completes authorization
158+
4. Token endpoint returns an access token on success
118159

119160
```ts
120-
{ source: 'oauth', authUrl: 'https://example.com/auth', port: 0, timeout: 120_000 }
161+
{
162+
source: 'device-code',
163+
clientId: 'my-client-id',
164+
deviceAuthUrl: 'https://example.com/device/code',
165+
tokenUrl: 'https://example.com/token',
166+
scopes: ['openid'],
167+
}
121168
```
122169

123-
| Option | Type | Default | Description |
124-
| -------------- | -------- | ------------- | ---------------------------- |
125-
| `authUrl` | `string` | -- | Authorization URL (required) |
126-
| `port` | `number` | `0` (random) | Local server port |
127-
| `callbackPath` | `string` | `'/callback'` | Callback endpoint path |
128-
| `timeout` | `number` | `120_000` | Timeout in milliseconds |
170+
| Option | Type | Default | Description |
171+
| --------------- | ------------------- | --------- | ---------------------------------------- |
172+
| `clientId` | `string` | -- | OAuth client ID (required) |
173+
| `deviceAuthUrl` | `string` | -- | Device authorization endpoint (required) |
174+
| `tokenUrl` | `string` | -- | Token endpoint (required) |
175+
| `scopes` | `readonly string[]` | `[]` | OAuth scopes to request |
176+
| `pollInterval` | `number` | `5_000` | Poll interval in milliseconds |
177+
| `timeout` | `number` | `300_000` | Timeout in milliseconds |
178+
179+
The device code flow handles RFC 8628 error codes: `authorization_pending` (continue polling), `slow_down` (increase interval), `expired_token` (return null), and `access_denied` (return null).
129180

130-
The auth URL receives a `callback_url` query parameter pointing to the local server. The OAuth provider must POST a JSON body `{ "token": "<value>" }` to this URL on success.
181+
Supported by GitHub, Azure AD, and Google. Not supported by Clerk.
131182

132183
### `prompt` -- Interactive Password Input
133184

@@ -197,7 +248,16 @@ cli({
197248
name: 'my-app',
198249
version: '1.0.0',
199250
middleware: [
200-
auth({ resolvers: [{ source: 'oauth', authUrl: 'https://example.com/auth' }] }),
251+
auth({
252+
resolvers: [
253+
{
254+
source: 'oauth',
255+
clientId: 'my-client-id',
256+
authUrl: 'https://example.com/authorize',
257+
tokenUrl: 'https://example.com/token',
258+
},
259+
],
260+
}),
201261
http({ baseUrl: 'https://api.example.com', namespace: 'api' }),
202262
],
203263
commands: { login, repos },
@@ -206,6 +266,12 @@ cli({
206266

207267
Header priority (lowest to highest): auth credential headers, default headers, per-request headers.
208268

269+
## Resources
270+
271+
- [RFC 7636 -- Proof Key for Code Exchange](https://tools.ietf.org/html/rfc7636)
272+
- [RFC 8252 -- OAuth 2.0 for Native Apps](https://tools.ietf.org/html/rfc8252)
273+
- [RFC 8628 -- Device Authorization Grant](https://tools.ietf.org/html/rfc8628)
274+
209275
## References
210276

211277
- [kidd API Reference](../reference/kidd.md)

docs/guides/add-authentication.md

Lines changed: 107 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@ cli({
2323
middleware: [
2424
auth({
2525
resolvers: [
26-
{ source: 'oauth', authUrl: 'https://example.com/auth' },
26+
{
27+
source: 'oauth',
28+
clientId: 'my-client-id',
29+
authUrl: 'https://example.com/authorize',
30+
tokenUrl: 'https://example.com/token',
31+
},
2732
{ source: 'prompt', message: 'Enter your API token:' },
2833
],
2934
}),
@@ -97,7 +102,12 @@ cli({
97102
middleware: [
98103
auth({
99104
resolvers: [
100-
{ source: 'oauth', authUrl: 'https://example.com/auth' },
105+
{
106+
source: 'oauth',
107+
clientId: 'my-client-id',
108+
authUrl: 'https://example.com/authorize',
109+
tokenUrl: 'https://example.com/token',
110+
},
101111
{ source: 'prompt', message: 'Enter your API token:' },
102112
],
103113
}),
@@ -145,12 +155,12 @@ export default command({
145155
return
146156
}
147157

148-
ctx.output.table(
149-
res.data.map((repo) => ({
150-
Name: repo.name,
151-
Private: repo.private ? 'yes' : 'no',
152-
}))
153-
)
158+
const rows = res.data.map((repo) => ({
159+
Name: repo.name,
160+
Private: repo.private,
161+
}))
162+
163+
ctx.output.table(rows)
154164
},
155165
})
156166
```
@@ -164,13 +174,82 @@ auth({
164174
resolvers: [
165175
{ source: 'env', tokenVar: 'MY_APP_TOKEN' },
166176
{ source: 'dotenv' },
167-
{ source: 'oauth', authUrl: 'https://example.com/auth' },
177+
{
178+
source: 'oauth',
179+
clientId: 'my-client-id',
180+
authUrl: 'https://example.com/authorize',
181+
tokenUrl: 'https://example.com/token',
182+
},
168183
{ source: 'prompt' },
169184
],
170185
})
171186
```
172187

173-
Passive resolvers (`env`, `dotenv`, `file`) run automatically on middleware init. Interactive resolvers (`oauth`, `prompt`, `custom`) only run when `ctx.auth.authenticate()` is called.
188+
Passive resolvers (`env`, `dotenv`, `file`) run automatically on middleware init. Interactive resolvers (`oauth`, `device-code`, `prompt`, `custom`) only run when `ctx.auth.authenticate()` is called.
189+
190+
### 7. Use PKCE with Clerk as the Identity Provider
191+
192+
Configure the OAuth resolver to use Clerk as a public OAuth application with PKCE:
193+
194+
```ts
195+
auth({
196+
resolvers: [
197+
{
198+
source: 'oauth',
199+
clientId: '<clerk-oauth-app-id>',
200+
authUrl: 'https://<clerk-domain>/oauth/authorize',
201+
tokenUrl: 'https://<clerk-domain>/oauth/token',
202+
scopes: ['openid', 'profile', 'email'],
203+
},
204+
],
205+
})
206+
```
207+
208+
### 8. Use the device code flow for headless environments
209+
210+
For environments without a browser (SSH sessions, remote servers), use the device code flow:
211+
212+
```ts
213+
auth({
214+
resolvers: [
215+
{
216+
source: 'device-code',
217+
clientId: 'my-client-id',
218+
deviceAuthUrl: 'https://github.com/login/device/code',
219+
tokenUrl: 'https://github.com/login/oauth/access_token',
220+
scopes: ['repo', 'read:user'],
221+
},
222+
],
223+
})
224+
```
225+
226+
The CLI displays a URL and a user code. The user opens the URL in any browser (including on a different device), enters the code, and completes authorization.
227+
228+
### 9. Combine multiple resolvers
229+
230+
Chain resolvers to support multiple authentication strategies:
231+
232+
```ts
233+
auth({
234+
resolvers: [
235+
{ source: 'env', tokenVar: 'MY_APP_TOKEN' },
236+
{ source: 'file' },
237+
{
238+
source: 'oauth',
239+
clientId: 'my-client-id',
240+
authUrl: 'https://example.com/authorize',
241+
tokenUrl: 'https://example.com/token',
242+
},
243+
{
244+
source: 'device-code',
245+
clientId: 'my-client-id',
246+
deviceAuthUrl: 'https://example.com/device/code',
247+
tokenUrl: 'https://example.com/token',
248+
},
249+
{ source: 'prompt' },
250+
],
251+
})
252+
```
174253

175254
## Verification
176255

@@ -190,11 +269,23 @@ MY_APP_TOKEN=ghp_abc123 my-app repos
190269

191270
## Troubleshooting
192271

193-
### OAuth callback never received
272+
### OAuth redirect not received
273+
274+
**Issue:** The browser opens but the CLI hangs waiting for the redirect.
275+
276+
**Fix:** Ensure the OAuth provider is configured to redirect to `http://127.0.0.1:<port>/callback` with `code` and `state` query parameters. Verify the `clientId` is correct and the application is configured as a public client with PKCE support. Check that no firewall is blocking the local port.
277+
278+
### Token exchange fails
279+
280+
**Issue:** The redirect is received but no credential is returned.
281+
282+
**Fix:** Verify the `tokenUrl` is correct and accepts `application/x-www-form-urlencoded` POST requests. Ensure the OAuth provider accepts the `code_verifier` parameter for PKCE validation.
283+
284+
### Device code flow times out
194285

195-
**Issue:** The browser opens but the CLI hangs waiting for the callback.
286+
**Issue:** The CLI polls but never receives a token.
196287

197-
**Fix:** Ensure the auth server sends a POST request to the `callback_url` query parameter with a JSON body `{ "token": "<value>" }`. Query-string tokens are not accepted. Check that no firewall is blocking the local port.
288+
**Fix:** Verify the `deviceAuthUrl` and `tokenUrl` are correct. Ensure the OAuth provider supports the Device Authorization Grant (RFC 8628). Clerk does not support this flow -- use the `oauth` resolver instead.
198289

199290
### Token not persisted after login
200291

@@ -211,6 +302,9 @@ MY_APP_TOKEN=ghp_abc123 my-app repos
211302
## Resources
212303

213304
- [@clack/prompts](https://www.clack.cc)
305+
- [RFC 7636 -- PKCE](https://tools.ietf.org/html/rfc7636)
306+
- [RFC 8252 -- OAuth for Native Apps](https://tools.ietf.org/html/rfc8252)
307+
- [RFC 8628 -- Device Authorization Grant](https://tools.ietf.org/html/rfc8628)
214308

215309
## References
216310

examples/authenticated-service/cli/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ cli({
1616
auth({
1717
resolvers: [
1818
{
19-
authUrl: 'http://localhost:3001/auth',
19+
authUrl: 'http://localhost:3001/authorize',
20+
clientId: 'demo-client',
2021
port: 0,
2122
source: 'oauth',
2223
timeout: 60_000,
24+
tokenUrl: 'http://localhost:3001/token',
2325
},
2426
{ message: 'Enter your API token (see README for valid tokens):', source: 'prompt' },
2527
],

0 commit comments

Comments
 (0)