| 1 | import { createHash } from 'node:crypto'; |
| 2 | |
| 3 | import { type Database, |
| 4 | import type { INestApplication |
| 5 | import { and, eq |
| 6 | import request from 'supertest |
| 7 | import { afterAll, beforeAll |
| 8 | |
| 9 | import { DATABASE } |
| 10 | import { hasBackends, signUp |
| 11 | |
| 12 | const CALLBACK = ' |
| 13 | |
| 14 | describe.skipIf(!hasBackends |
| 15 | let app: |
| 16 | let owner: |
| 17 | const username = |
| 18 | let clientId: |
| 19 | let clientSecret: |
| 20 | const api = |
| 21 | const authorize = |
| 22 | api().get(' |
| 23 | const follow = |
| 24 | query: Record |
| 25 | cookie = owner. |
| 26 | ) => { |
| 27 | const first = |
| 28 | const plugin = |
| 29 | expect(plugin.pathname |
| 30 | const second = |
| 31 | .get(`${plugin |
| 32 | .set('cookie', |
| 33 | .expect(302); |
| 34 | return new URL |
| 35 | }; |
| 36 | const decide = |
| 37 | const response = |
| 38 | .post('/api/auth/oauth2/consent') |
| 39 | .set('cookie', |
| 40 | .send({ accept |
| 41 | .expect(200); |
| 42 | return new URL |
| 43 | }; |
| 44 | |
| 45 | const codeFor = |
| 46 | const consent = |
| 47 | client_id: clientId, |
| 48 | scope: 'repo', |
| 49 | ...query, |
| 50 | }); |
| 51 | const callback = |
| 52 | ? await decide( |
| 53 | : consent; |
| 54 | return callback.searchParams |
| 55 | }; |
| 56 | const exchange = |
| 57 | body: Record |
| 58 | accept = 'application/x-www-form-urlencoded' |
| 59 | ) => |
| 60 | api() |
| 61 | .post('/login/oauth/access_token') |
| 62 | .set('accept', |
| 63 | .type('form') |
| 64 | .send({ client_id: |
| 65 | .expect(200); |
| 66 | const form = |
| 67 | |
| 68 | beforeAll(async () |
| 69 | ({ app } |
| 70 | owner = await |
| 71 | const created = |
| 72 | .post('/api/oauth-apps') |
| 73 | .set('cookie', |
| 74 | .send({ |
| 75 | name: 'Web Tool', |
| 76 | homepageUrl: 'https://tool.example', |
| 77 | callbackUrls: [CALLBACK], |
| 78 | }) |
| 79 | .expect(201); |
| 80 | ({ clientId, clientSecret |
| 81 | }); |
| 82 | |
| 83 | afterAll(async () |
| 84 | await app?.close |
| 85 | }); |
| 86 | |
| 87 | it('answers an unknown client with an error page and never redirects', |
| 88 | const response = |
| 89 | client_id: 'nope', |
| 90 | redirect_uri: CALLBACK, |
| 91 | }).expect(400) |
| 92 | expect(response.headers |
| 93 | expect(response.headers |
| 94 | }); |
| 95 | |
| 96 | it.each([ |
| 97 | ['another host', 'https://evil.example/oauth/callback' |
| 98 | ['another port', 'https://tool.example:8443/oauth/callback' |
| 99 | ['a path outside the callback', 'https://tool.example/elsewhere' |
| 100 | ])( |
| 101 | 'answers a redirect_uri on %s with an error page and never redirects', |
| 102 | async (_, |
| 103 | const response = |
| 104 | client_id: clientId, |
| 105 | redirect_uri: redirectUri, |
| 106 | }).expect(400) |
| 107 | expect(response.headers |
| 108 | }, |
| 109 | ); |
| 110 | |
| 111 | it('sends the user to the consent page with only the scopes Ghost knows', |
| 112 | const consent = |
| 113 | client_id: clientId, |
| 114 | scope: 'repo bogus read:org', |
| 115 | state: 'abc', |
| 116 | }); |
| 117 | expect(`${consent. |
| 118 | 'https://web.example/login/oauth/authorize', |
| 119 | ); |
| 120 | expect(consent.searchParams |
| 121 | expect(consent.searchParams |
| 122 | }); |
| 123 | |
| 124 | it('asks for no scope when none is requested, rather than every scope', |
| 125 | const consent = |
| 126 | expect(consent.searchParams |
| 127 | }); |
| 128 | |
| 129 | it('redirects to a path under the callback with a code and the state', |
| 130 | const consent = |
| 131 | client_id: clientId, |
| 132 | redirect_uri: `${CALLBACK} |
| 133 | scope: 'gist', |
| 134 | state: 'xyz', |
| 135 | }); |
| 136 | const callback = |
| 137 | expect(`${callback. |
| 138 | expect(callback.searchParams |
| 139 | expect(callback.searchParams |
| 140 | }); |
| 141 | |
| 142 | it('carries access_denied and the state back on deny', |
| 143 | const consent = |
| 144 | client_id: clientId, |
| 145 | scope: 'user', |
| 146 | state: 'no', |
| 147 | }); |
| 148 | const callback = |
| 149 | expect(callback.searchParams |
| 150 | expect(callback.searchParams |
| 151 | }); |
| 152 | |
| 153 | it('skips the consent page once the same scopes were approved', |
| 154 | await decide( |
| 155 | await follow({ |
| 156 | true, |
| 157 | ); |
| 158 | const again = |
| 159 | client_id: clientId, |
| 160 | scope: 'read:org', |
| 161 | state: 's2', |
| 162 | }); |
| 163 | expect(`${again. |
| 164 | expect(again.searchParams |
| 165 | expect(again.searchParams |
| 166 | }); |
| 167 | |
| 168 | it('asks again with prompt=consent, even once the same scopes were approved', |
| 169 | await decide( |
| 170 | await follow({ |
| 171 | true, |
| 172 | ); |
| 173 | const again = |
| 174 | client_id: clientId, |
| 175 | scope: 'admin:public_key', |
| 176 | prompt: 'consent', |
| 177 | }); |
| 178 | expect(`${again. |
| 179 | 'https://web.example/login/oauth/authorize', |
| 180 | ); |
| 181 | expect(again.searchParams |
| 182 | }); |
| 183 | |
| 184 | it('accepts a redirect_uri under any registered callback, and sends users to the first by default', |
| 185 | const created = |
| 186 | .post('/api/oauth-apps') |
| 187 | .set('cookie', |
| 188 | .send({ |
| 189 | name: 'Two Callbacks', |
| 190 | homepageUrl: 'https://tool.example', |
| 191 | callbackUrls: [CALLBACK, |
| 192 | }) |
| 193 | .expect(201); |
| 194 | const local = |
| 195 | client_id: created.body |
| 196 | redirect_uri: 'http://localhost:4000/cb/next', |
| 197 | scope: 'repo', |
| 198 | }); |
| 199 | expect(`${(await |
| 200 | 'http://localhost:4000', |
| 201 | ); |
| 202 | const fallback = |
| 203 | client_id: created.body |
| 204 | scope: 'gist', |
| 205 | }); |
| 206 | const callback = |
| 207 | expect(`${callback. |
| 208 | }); |
| 209 | |
| 210 | it('sends a signed-out user to the same page to sign in first', |
| 211 | const consent = |
| 212 | expect(`${consent. |
| 213 | 'https://web.example/login/oauth/authorize', |
| 214 | ); |
| 215 | }); |
| 216 | |
| 217 | it('keeps the client secret out of every redirect', |
| 218 | const consent = |
| 219 | expect(consent.href |
| 220 | }); |
| 221 | |
| 222 | it('describes the app to the consent page: name, homepage and owner, never the secret', |
| 223 | const response = |
| 224 | .get('/api/oauth-apps/authorize') |
| 225 | .query({ client_id: |
| 226 | .set('cookie', |
| 227 | .expect(200); |
| 228 | expect(response.body |
| 229 | clientId, |
| 230 | name: 'Web Tool', |
| 231 | description: null, |
| 232 | homepageUrl: 'https://tool.example', |
| 233 | logoUrl: null, |
| 234 | owner: expect.stringMatching |
| 235 | verified: false, |
| 236 | }); |
| 237 | const gh = |
| 238 | .get('/api/oauth-apps/authorize') |
| 239 | .query({ client_id: |
| 240 | .set('cookie', |
| 241 | .expect(200); |
| 242 | expect(gh.body |
| 243 | clientId: '178c6fc778ccc68e1d6a', |
| 244 | name: 'GitHub CLI', |
| 245 | description: null, |
| 246 | homepageUrl: 'https://cli.github.com', |
| 247 | logoUrl: null, |
| 248 | owner: null, |
| 249 | verified: true, |
| 250 | }); |
| 251 | await api() |
| 252 | .get('/api/oauth-apps/authorize') |
| 253 | .query({ client_id: |
| 254 | .set('cookie', |
| 255 | .expect(404); |
| 256 | }); |
| 257 | |
| 258 | it('exchanges a code for a ghost_pat_ key with the approved scopes, form-encoded by default', |
| 259 | const code = |
| 260 | const response = |
| 261 | expect(response.headers |
| 262 | /application\/x-www-form-urlencoded/, |
| 263 | ); |
| 264 | const token = |
| 265 | expect(token).toMatchObject |
| 266 | token_type: 'bearer', |
| 267 | scope: 'repo,read:org', |
| 268 | }); |
| 269 | expect(token.access_token |
| 270 | const user = |
| 271 | .get('/api/v3/user') |
| 272 | .set('authorization', |
| 273 | .expect(200); |
| 274 | expect(user.headers |
| 275 | expect(user.body |
| 276 | await api() |
| 277 | .get('/api/notifications') |
| 278 | .set('authorization', |
| 279 | .expect(401); |
| 280 | const live = |
| 281 | .get<Database |
| 282 | .select({ id: |
| 283 | .from(schema.oauthAccessToken |
| 284 | .where( |
| 285 | and( |
| 286 | eq(schema.oauthAccessToken |
| 287 | isNull(schema.oauthAccessToken |
| 288 | ), |
| 289 | ); |
| 290 | expect(live).toEqual |
| 291 | }); |
| 292 | |
| 293 | it('answers JSON when asked', |
| 294 | const response = |
| 295 | { code: await |
| 296 | 'application/json', |
| 297 | ); |
| 298 | expect(response.body |
| 299 | token_type: 'bearer', |
| 300 | scope: 'repo', |
| 301 | }); |
| 302 | expect(response.body |
| 303 | }); |
| 304 | |
| 305 | it('mints a key with no scope when the app asked for none', |
| 306 | const token = |
| 307 | (await exchange({ |
| 308 | ); |
| 309 | expect(token.scope |
| 310 | const user = |
| 311 | .get('/api/v3/user') |
| 312 | .set('authorization', |
| 313 | .expect(200); |
| 314 | expect(user.headers |
| 315 | }); |
| 316 | |
| 317 | it('yields a key for a code only once', |
| 318 | const code = |
| 319 | expect(form((await |
| 320 | /^ghost_pat_/, |
| 321 | ); |
| 322 | expect(form((await |
| 323 | error: 'bad_verification_code', |
| 324 | error_description: 'The code passed is incorrect or expired.', |
| 325 | }); |
| 326 | }); |
| 327 | |
| 328 | it('refuses a wrong secret, and the secret a rotation replaced', |
| 329 | expect( |
| 330 | form( |
| 331 | (await exchange({ |
| 332 | .text, |
| 333 | ), |
| 334 | ).toMatchObject({ |
| 335 | error: 'incorrect_client_credentials', |
| 336 | error_description: |
| 337 | 'The client_id and/or client_secret passed are incorrect.', |
| 338 | }); |
| 339 | const other = |
| 340 | .post('/api/oauth-apps') |
| 341 | .set('cookie', |
| 342 | .send({ |
| 343 | name: 'Rotating', |
| 344 | homepageUrl: 'https://tool.example', |
| 345 | callbackUrls: [CALLBACK], |
| 346 | }) |
| 347 | .expect(201); |
| 348 | const rotated = |
| 349 | .post(`/api/oauth-apps/${other |
| 350 | .set('cookie', |
| 351 | .expect(201); |
| 352 | const consent = |
| 353 | client_id: other.body |
| 354 | scope: 'repo', |
| 355 | }); |
| 356 | const code = |
| 357 | 'code', |
| 358 | ) as string; |
| 359 | const stale = |
| 360 | client_id: other.body |
| 361 | client_secret: other.body |
| 362 | code, |
| 363 | }); |
| 364 | expect(form(stale |
| 365 | error: 'incorrect_client_credentials', |
| 366 | error_description: |
| 367 | 'The client_id and/or client_secret passed are incorrect.', |
| 368 | }); |
| 369 | const again = |
| 370 | await decide( |
| 371 | await follow({ |
| 372 | true, |
| 373 | ) |
| 374 | ).searchParams.get( |
| 375 | const fresh = |
| 376 | client_id: other.body |
| 377 | client_secret: rotated.body |
| 378 | code: again, |
| 379 | }); |
| 380 | expect(form(fresh |
| 381 | }); |
| 382 | |
| 383 | it("refuses a code presented with another app's credentials", |
| 384 | const code = |
| 385 | const other = |
| 386 | .post('/api/oauth-apps') |
| 387 | .set('cookie', |
| 388 | .send({ |
| 389 | name: 'Other', |
| 390 | homepageUrl: 'https://tool.example', |
| 391 | callbackUrls: [CALLBACK], |
| 392 | }) |
| 393 | .expect(201); |
| 394 | const response = |
| 395 | client_id: other.body |
| 396 | client_secret: other.body |
| 397 | code, |
| 398 | }); |
| 399 | expect(form(response |
| 400 | expect(form(response |
| 401 | }); |
| 402 | |
| 403 | it('refuses an unknown client and a redirect_uri outside the callback', |
| 404 | expect( |
| 405 | form((await exchange |
| 406 | ).toMatchObject({ |
| 407 | error: 'incorrect_client_credentials', |
| 408 | error_description: |
| 409 | 'The client_id and/or client_secret passed are incorrect.', |
| 410 | }); |
| 411 | expect( |
| 412 | form( |
| 413 | ( |
| 414 | await exchange({ |
| 415 | code: await codeFor |
| 416 | redirect_uri: 'https://evil.example/cb', |
| 417 | }) |
| 418 | ).text, |
| 419 | ), |
| 420 | ).toMatchObject({ |
| 421 | error: 'redirect_uri_mismatch', |
| 422 | error_description: |
| 423 | 'The redirect_uri MUST match the registered callback URL for this application.', |
| 424 | }); |
| 425 | }); |
| 426 | |
| 427 | it('refuses an expired code', |
| 428 | const code = |
| 429 | const db = |
| 430 | // The plugin stores a code as its SHA-256, base64url. |
| 431 | const expired = |
| 432 | .update(schema.verification |
| 433 | .set({ expiresAt: |
| 434 | .where( |
| 435 | eq( |
| 436 | schema.verification.identifier |
| 437 | createHash('sha256'). |
| 438 | ), |
| 439 | ) |
| 440 | .returning({ id: |
| 441 | expect(expired).toHaveLength |
| 442 | expect(form((await |
| 443 | error: 'bad_verification_code', |
| 444 | error_description: 'The code passed is incorrect or expired.', |
| 445 | }); |
| 446 | }); |
| 447 | |
| 448 | it('checks the PKCE verifier against the S256 challenge', |
| 449 | const verifier = |
| 450 | const challenge = |
| 451 | const wrong = |
| 452 | code_challenge: challenge, |
| 453 | code_challenge_method: 'S256', |
| 454 | }); |
| 455 | expect( |
| 456 | form( |
| 457 | (await exchange({ |
| 458 | ), |
| 459 | ).toMatchObject({ |
| 460 | error: 'bad_verification_code', |
| 461 | error_description: 'The code passed is incorrect or expired.', |
| 462 | }); |
| 463 | const right = |
| 464 | code_challenge: challenge, |
| 465 | code_challenge_method: 'S256', |
| 466 | }); |
| 467 | expect( |
| 468 | form((await exchange |
| 469 | .access_token, |
| 470 | ).toMatch(/^ghost_pat_ |
| 471 | }); |
| 472 | |
| 473 | it('answers a request with neither a device code nor a code with unsupported_grant_type', |
| 474 | const response = |
| 475 | .post('/login/oauth/access_token') |
| 476 | .type('form') |
| 477 | .send({ client_id: |
| 478 | .expect(400); |
| 479 | expect(form(response |
| 480 | error: 'unsupported_grant_type', |
| 481 | error_description: |
| 482 | 'Send a code, a device_code with the device grant_type, or a refresh_token with grant_type=refresh_token.', |
| 483 | }); |
| 484 | }); |
| 485 | }); |