provider.test.js (14521B)
1 // test/provider.test.js - the built-in login provider 2 3 import test from 'node:test'; 4 import assert from 'node:assert/strict'; 5 import crypto from 'node:crypto'; 6 import { startHarness } from './helpers/harness.js'; 7 8 const PUBLIC_URL = 'http://conductor.test'; 9 const REDIRECT = `${PUBLIC_URL}/callback`; 10 11 function pkce() { 12 const verifier = crypto.randomBytes(32).toString('base64url'); 13 return { verifier, challenge: crypto.createHash('sha256').update(verifier).digest('base64url') }; 14 } 15 16 function authorizeQuery(overrides = {}) { 17 return new URLSearchParams({ 18 client_id: 'conductor', 19 redirect_uri: REDIRECT, 20 response_type: 'code', 21 code_challenge: pkce().challenge, 22 code_challenge_method: 'S256', 23 state: 'xyz', 24 ...overrides, 25 }); 26 } 27 28 async function withConductor(fn) { 29 const c = await startHarness({ bootstrap: true, publicUrl: PUBLIC_URL }); 30 try { 31 return await fn(c); 32 } finally { 33 await c.stop(); 34 } 35 } 36 37 async function authorize(c, query, credentials = { username: 'admin', password: 'bootstrap-password' }) { 38 return c.app.inject({ 39 method: 'POST', 40 url: `/auth/authorize?${query}`, 41 headers: { 'content-type': 'application/x-www-form-urlencoded' }, 42 payload: new URLSearchParams(credentials).toString(), 43 }); 44 } 45 46 test('discovery advertises the built-in endpoints and the client id', async () => { 47 await withConductor(async (c) => { 48 const res = await c.discovery(); 49 assert.equal(res.statusCode, 200); 50 51 const doc = res.json(); 52 assert.equal(doc.issuer, PUBLIC_URL); 53 assert.equal(doc.authorization_endpoint, `${PUBLIC_URL}/auth/authorize`); 54 assert.equal(doc.token_endpoint, `${PUBLIC_URL}/auth/token`); 55 assert.equal(doc.end_session_endpoint, `${PUBLIC_URL}/auth/logout`); 56 assert.deepEqual(doc.code_challenge_methods_supported, ['S256']); 57 assert.equal(doc.client_id, 'conductor'); 58 }); 59 }); 60 61 test('the login page is served for a valid authorization request', async () => { 62 await withConductor(async (c) => { 63 const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${authorizeQuery()}` }); 64 assert.equal(res.statusCode, 200); 65 assert.match(res.headers['content-type'], /text\/html/); 66 assert.match(res.body, /name="password"/); 67 // The query string carries the parameters, so the form needs no action. 68 assert.ok(!res.body.includes('action=')); 69 }); 70 }); 71 72 test('the whole code flow yields a working access token', async () => { 73 await withConductor(async (c) => { 74 const grant = await c.login(); 75 assert.equal(grant.token_type, 'Bearer'); 76 assert.equal(grant.expires_in, 900); 77 assert.ok(grant.access_token); 78 assert.ok(grant.refresh_token); 79 80 const me = await c.app.inject({ method: 'GET', url: '/api/v1/users/me', headers: grant.headers }); 81 assert.equal(me.statusCode, 200); 82 assert.equal(me.json().data.username, 'admin'); 83 }); 84 }); 85 86 test('a code is single use', async () => { 87 await withConductor(async (c) => { 88 const { verifier, challenge } = pkce(); 89 const res = await authorize(c, authorizeQuery({ code_challenge: challenge })); 90 const code = new URL(res.headers.location).searchParams.get('code'); 91 92 const body = { 93 grant_type: 'authorization_code', code, redirect_uri: REDIRECT, code_verifier: verifier, 94 }; 95 assert.equal((await c.token(body)).statusCode, 200); 96 97 const replay = await c.token(body); 98 assert.equal(replay.statusCode, 400); 99 assert.equal(replay.json().error, 'invalid_grant'); 100 }); 101 }); 102 103 test('a code will not redeem without the matching verifier', async () => { 104 await withConductor(async (c) => { 105 const { challenge } = pkce(); 106 const res = await authorize(c, authorizeQuery({ code_challenge: challenge })); 107 const code = new URL(res.headers.location).searchParams.get('code'); 108 109 const wrong = await c.token({ 110 grant_type: 'authorization_code', 111 code, 112 redirect_uri: REDIRECT, 113 code_verifier: crypto.randomBytes(32).toString('base64url'), 114 }); 115 assert.equal(wrong.statusCode, 400); 116 assert.equal(wrong.json().error, 'invalid_grant'); 117 }); 118 }); 119 120 test('a code will not redeem against a different redirect uri', async () => { 121 await withConductor(async (c) => { 122 const { verifier, challenge } = pkce(); 123 const res = await authorize(c, authorizeQuery({ code_challenge: challenge })); 124 const code = new URL(res.headers.location).searchParams.get('code'); 125 126 const moved = await c.token({ 127 grant_type: 'authorization_code', 128 code, 129 redirect_uri: `${PUBLIC_URL}/elsewhere`, 130 code_verifier: verifier, 131 }); 132 assert.equal(moved.statusCode, 400); 133 }); 134 }); 135 136 // Redirecting to an unregistered uri would make this an open redirect. 137 test('an unregistered redirect uri is refused without redirecting', async () => { 138 await withConductor(async (c) => { 139 const query = authorizeQuery({ redirect_uri: 'https://evil.example/steal', code_challenge: pkce().challenge }); 140 141 const shown = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` }); 142 assert.equal(shown.statusCode, 400); 143 assert.equal(shown.headers.location, undefined); 144 145 const posted = await authorize(c, query); 146 assert.equal(posted.statusCode, 400); 147 assert.equal(posted.headers.location, undefined); 148 }); 149 }); 150 151 // localhost and 127.0.0.1 are different origins, which is the mistake this 152 // message has to be able to explain. 153 test('a refused redirect uri names what is registered', async () => { 154 await withConductor(async (c) => { 155 const query = authorizeQuery({ redirect_uri: 'http://localhost:8080/' }); 156 const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` }); 157 158 assert.equal(res.statusCode, 400); 159 assert.match(res.body, /localhost:8080/); 160 assert.match(res.body, /Registered: http:\/\/conductor\.test/); 161 assert.match(res.body, /CONDUCTOR_LOCAL_REDIRECT_URIS/); 162 }); 163 }); 164 165 test('several origins may be registered at once', async () => { 166 const c = await startHarness({ 167 bootstrap: true, 168 publicUrl: PUBLIC_URL, 169 redirectUris: 'http://127.0.0.1:18080,http://localhost:18080', 170 }); 171 try { 172 for (const origin of ['http://127.0.0.1:18080', 'http://localhost:18080']) { 173 const query = authorizeQuery({ redirect_uri: `${origin}/` }); 174 const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` }); 175 assert.equal(res.statusCode, 200, `${origin} should be registered`); 176 } 177 178 // Anything outside the list is still refused. 179 for (const origin of ['http://127.0.0.1:19999', 'https://evil.example']) { 180 const query = authorizeQuery({ redirect_uri: `${origin}/` }); 181 const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` }); 182 assert.equal(res.statusCode, 400, `${origin} should be refused`); 183 } 184 } finally { 185 await c.stop(); 186 } 187 }); 188 189 test('a star registration allows any origin but still refuses nonsense', async () => { 190 const c = await startHarness({ bootstrap: true, publicUrl: PUBLIC_URL, redirectUris: '*' }); 191 try { 192 for (const uri of ['http://localhost:3000/', 'https://anything.example/callback']) { 193 const query = authorizeQuery({ redirect_uri: uri }); 194 const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` }); 195 assert.equal(res.statusCode, 200, `${uri} should be allowed`); 196 } 197 198 for (const uri of ['javascript:alert(1)', 'data:text/html,x', '']) { 199 const query = authorizeQuery({ redirect_uri: uri }); 200 const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` }); 201 assert.equal(res.statusCode, 400, `${JSON.stringify(uri)} must still be refused`); 202 } 203 } finally { 204 await c.stop(); 205 } 206 }); 207 208 test('a bad request with a good redirect uri comes back as an oauth error', async () => { 209 await withConductor(async (c) => { 210 for (const [override, error] of [ 211 [{ response_type: 'token' }, 'unsupported_response_type'], 212 [{ code_challenge_method: 'plain' }, 'invalid_request'], 213 [{ client_id: 'someone-else' }, 'unauthorized_client'], 214 ]) { 215 const query = authorizeQuery({ code_challenge: pkce().challenge, ...override }); 216 const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` }); 217 218 assert.equal(res.statusCode, 303); 219 const url = new URL(res.headers.location); 220 assert.equal(url.origin + url.pathname, REDIRECT); 221 assert.equal(url.searchParams.get('error'), error); 222 assert.equal(url.searchParams.get('state'), 'xyz'); 223 } 224 }); 225 }); 226 227 test('a request with no code challenge at all is refused', async () => { 228 await withConductor(async (c) => { 229 const query = authorizeQuery(); 230 query.delete('code_challenge'); 231 const res = await c.app.inject({ method: 'GET', url: `/auth/authorize?${query}` }); 232 233 assert.equal(res.statusCode, 303); 234 assert.equal(new URL(res.headers.location).searchParams.get('error'), 'invalid_request'); 235 }); 236 }); 237 238 test('wrong credentials redisplay the form rather than issuing a code', async () => { 239 await withConductor(async (c) => { 240 const res = await authorize(c, authorizeQuery({ code_challenge: pkce().challenge }), { 241 username: 'admin', password: 'not-the-password', 242 }); 243 244 assert.equal(res.statusCode, 401); 245 assert.equal(res.headers.location, undefined); 246 assert.match(res.body, /did not match/); 247 }); 248 }); 249 250 test('the state is handed back so the app can resume', async () => { 251 await withConductor(async (c) => { 252 const res = await authorize(c, authorizeQuery({ code_challenge: pkce().challenge, state: 'resume-me' })); 253 assert.equal(new URL(res.headers.location).searchParams.get('state'), 'resume-me'); 254 }); 255 }); 256 257 test('refreshing returns a new access token and keeps the refresh token', async () => { 258 await withConductor(async (c) => { 259 const grant = await c.login(); 260 261 const res = await c.token({ grant_type: 'refresh_token', refresh_token: grant.refresh_token }); 262 assert.equal(res.statusCode, 200); 263 264 const next = res.json(); 265 assert.equal(next.refresh_token, grant.refresh_token); 266 assert.ok(next.access_token); 267 268 const me = await c.app.inject({ 269 method: 'GET', 270 url: '/api/v1/users/me', 271 headers: { authorization: `Bearer ${next.access_token}` }, 272 }); 273 assert.equal(me.statusCode, 200); 274 }); 275 }); 276 277 async function refreshedDeadline(sliding) { 278 const c = await startHarness({ 279 bootstrap: true, 280 publicUrl: PUBLIC_URL, 281 refreshSliding: sliding, 282 refreshTtl: 3600, 283 }); 284 try { 285 const grant = await c.login(); 286 const before = await c.services.refreshTokens.claim(grant.refresh_token); 287 288 await new Promise((resolve) => setTimeout(resolve, 5)); 289 const res = await c.token({ grant_type: 'refresh_token', refresh_token: grant.refresh_token }); 290 assert.equal(res.statusCode, 200, res.body); 291 292 const after = await c.services.refreshTokens.claim(grant.refresh_token); 293 return { before: before.expires_at, after: after.expires_at }; 294 } finally { 295 await c.stop(); 296 } 297 } 298 299 test('a sliding session has its deadline extended by refreshing', async () => { 300 const { before, after } = await refreshedDeadline(true); 301 assert.ok(after > before, `${after} should be past ${before}`); 302 }); 303 304 test('an absolute session keeps its deadline when refreshed', async () => { 305 const { before, after } = await refreshedDeadline(false); 306 assert.equal(after, before); 307 }); 308 309 test('an unknown grant type or refresh token is refused', async () => { 310 await withConductor(async (c) => { 311 const bad = await c.token({ grant_type: 'password', username: 'admin', password: 'bootstrap-password' }); 312 assert.equal(bad.statusCode, 400); 313 assert.equal(bad.json().error, 'unsupported_grant_type'); 314 315 const unknown = await c.token({ grant_type: 'refresh_token', refresh_token: 'a'.repeat(64) }); 316 assert.equal(unknown.statusCode, 400); 317 assert.equal(unknown.json().error, 'invalid_grant'); 318 }); 319 }); 320 321 test('revoking ends the session and reports nothing about unknown tokens', async () => { 322 await withConductor(async (c) => { 323 const grant = await c.login(); 324 325 const unknown = await c.app.inject({ 326 method: 'POST', 327 url: '/auth/revoke', 328 headers: { 'content-type': 'application/x-www-form-urlencoded' }, 329 payload: new URLSearchParams({ token: 'b'.repeat(64) }).toString(), 330 }); 331 assert.equal(unknown.statusCode, 200); 332 333 const revoked = await c.app.inject({ 334 method: 'POST', 335 url: '/auth/revoke', 336 headers: { 'content-type': 'application/x-www-form-urlencoded' }, 337 payload: new URLSearchParams({ token: grant.refresh_token }).toString(), 338 }); 339 assert.equal(revoked.statusCode, 200); 340 341 const after = await c.token({ grant_type: 'refresh_token', refresh_token: grant.refresh_token }); 342 assert.equal(after.statusCode, 400); 343 }); 344 }); 345 346 test('logout redirects only to a registered uri', async () => { 347 await withConductor(async (c) => { 348 const ok = await c.app.inject({ 349 method: 'GET', 350 url: `/auth/logout?post_logout_redirect_uri=${encodeURIComponent(PUBLIC_URL)}`, 351 }); 352 assert.equal(ok.statusCode, 303); 353 assert.equal(ok.headers.location, PUBLIC_URL); 354 355 const bad = await c.app.inject({ 356 method: 'GET', 357 url: '/auth/logout?post_logout_redirect_uri=https%3A%2F%2Fevil.example', 358 }); 359 assert.equal(bad.statusCode, 400); 360 }); 361 }); 362 363 test('logout ignores id_token_hint and client_id', async () => { 364 await withConductor(async (c) => { 365 const res = await c.app.inject({ 366 method: 'GET', 367 url: `/auth/logout?post_logout_redirect_uri=${encodeURIComponent(PUBLIC_URL)}` 368 + '&client_id=conductor&id_token_hint=whatever', 369 }); 370 assert.equal(res.statusCode, 303); 371 assert.equal(res.headers.location, PUBLIC_URL); 372 }); 373 }); 374 375 test('the key set is advertised and empty', async () => { 376 await withConductor(async (c) => { 377 const res = await c.app.inject({ method: 'GET', url: '/auth/jwks' }); 378 assert.equal(res.statusCode, 200); 379 assert.deepEqual(res.json(), { keys: [] }); 380 }); 381 }); 382 383 test('a disabled account cannot refresh an existing session', async () => { 384 await withConductor(async (c) => { 385 const grant = await c.login(); 386 const admin = await c.services.users.byUsername('admin'); 387 await c.services.users.setDisabled(admin.id, true); 388 389 const res = await c.token({ grant_type: 'refresh_token', refresh_token: grant.refresh_token }); 390 assert.equal(res.statusCode, 400); 391 assert.equal(res.json().error, 'invalid_grant'); 392 }); 393 });