oidc.js (7590B)
1 // test/helpers/oidc.js - a real identity provider for the tests 2 // 3 // Jobs navikt/mock-oauth2-server, which speaks real OIDC discovery and 4 // signs real tokens with a real key set. Faking the provider would test the 5 // fake: the interesting failures here are discovery paths, signature 6 // verification and claim shapes, and none of those survive a stub. 7 // 8 // Personas are declared up front as token callbacks and requested by scope, 9 // so a test can ask for a specific set of claims without a browser. 10 11 import { execFile } from 'node:child_process'; 12 import { promisify } from 'node:util'; 13 import crypto from 'node:crypto'; 14 import { reapStale } from './containers.js'; 15 import { SESSION_COOKIE } from '../../src/lib/auth/token.js'; 16 17 const execFileAsync = promisify(execFile); 18 19 export const IMAGE = 'ghcr.io/navikt/mock-oauth2-server:2.1.10'; 20 const ISSUER_ID = 'conductor'; 21 22 // Each persona is selected with scope=<name> at the token endpoint. 23 const PERSONAS = { 24 'persona-admin': { 25 sub: 'alice-sub', 26 preferred_username: 'alice', 27 realm_access: { roles: ['conductor-admin'] }, 28 aud: ['conductor'], 29 }, 30 'persona-viewer': { 31 sub: 'bob-sub', 32 preferred_username: 'bob', 33 realm_access: { roles: ['developer'] }, 34 aud: ['conductor'], 35 }, 36 // The same person as persona-admin, to prove identity is keyed on the 37 // subject rather than on a display name the provider may change. 38 'persona-admin-renamed': { 39 sub: 'alice-sub', 40 preferred_username: 'alice-moved', 41 realm_access: { roles: ['conductor-admin'] }, 42 aud: ['conductor'], 43 }, 44 // Roles arriving in the other shapes providers use. 45 'persona-groups': { 46 sub: 'carol-sub', 47 preferred_username: 'carol', 48 groups: ['conductor-admin'], 49 aud: ['conductor'], 50 }, 51 'persona-resource': { 52 sub: 'dave-sub', 53 preferred_username: 'dave', 54 resource_access: { conductor: { roles: ['conductor-admin'] } }, 55 aud: ['conductor'], 56 }, 57 // A display name that collides with an existing local account. 58 'persona-collision': { 59 sub: 'eve-sub', 60 preferred_username: 'admin', 61 realm_access: { roles: ['developer'] }, 62 aud: ['conductor'], 63 }, 64 // Audience the conductor is not expecting. 65 'persona-wrong-audience': { 66 sub: 'frank-sub', 67 preferred_username: 'frank', 68 realm_access: { roles: ['conductor-admin'] }, 69 aud: ['some-other-service'], 70 }, 71 // Already expired when issued. 72 'persona-expired': { 73 sub: 'grace-sub', 74 preferred_username: 'grace', 75 realm_access: { roles: ['conductor-admin'] }, 76 aud: ['conductor'], 77 exp: 1000000000, 78 }, 79 }; 80 81 export async function dockerAvailable() { 82 if (process.env.CONDUCTOR_TEST_NO_DOCKER) return false; 83 try { 84 await execFileAsync('docker', ['version', '--format', '{{.Server.Version}}'], { timeout: 15000 }); 85 return true; 86 } catch { 87 return false; 88 } 89 } 90 91 async function freePort() { 92 const net = await import('node:net'); 93 return new Promise((resolve, reject) => { 94 const server = net.createServer(); 95 server.unref(); 96 server.on('error', reject); 97 server.listen(0, '127.0.0.1', () => { 98 const { port } = server.address(); 99 server.close(() => resolve(port)); 100 }); 101 }); 102 } 103 104 // How long to wait for the container to answer. Generous, because it 105 // competes with image builds for the machine. 106 const READY_TIMEOUT = 120 * 1000; 107 108 const NAME_PREFIX = 'conductor-oidc-test-'; 109 110 export async function startOidc() { 111 await reapStale(NAME_PREFIX); 112 113 const port = await freePort(); 114 const name = `${NAME_PREFIX}${crypto.randomBytes(4).toString('hex')}`; 115 const issuer = `http://127.0.0.1:${port}/${ISSUER_ID}`; 116 117 const config = { 118 interactiveLogin: true, 119 tokenCallbacks: [{ 120 issuerId: ISSUER_ID, 121 requestMappings: Object.entries(PERSONAS).map(([scope, claims]) => ({ 122 requestParam: 'scope', 123 match: scope, 124 claims, 125 })), 126 }], 127 }; 128 129 await execFileAsync('docker', [ 130 'run', '--detach', '--rm', 131 '--name', name, 132 '--publish', `${port}:8080`, 133 '--env', `JSON_CONFIG=${JSON.stringify(config)}`, 134 IMAGE, 135 ], { timeout: 120000 }); 136 137 const discovery = `${issuer}/.well-known/openid-configuration`; 138 // Deadline rather than a count of attempts. A refused connection comes 139 // back instantly, so sixty attempts spaced by half a second gave up 140 // after thirty seconds regardless of the interval, and a machine busy 141 // pulling or building images needs longer than that. Failing there 142 // looked like a broken test rather than a slow start. 143 let ready = false; 144 const deadline = Date.now() + READY_TIMEOUT; 145 while (Date.now() < deadline) { 146 try { 147 const res = await fetch(discovery, { signal: AbortSignal.timeout(2000) }); 148 if (res.ok) { 149 ready = true; 150 break; 151 } 152 } catch { 153 // Still starting. 154 } 155 await new Promise((r) => { setTimeout(r, 500); }); 156 } 157 158 if (!ready) { 159 await execFileAsync('docker', ['rm', '-f', name]).catch(() => {}); 160 throw new Error(`the OIDC provider did not become ready at ${discovery} within ${READY_TIMEOUT / 1000}s`); 161 } 162 163 return { 164 issuer, 165 port, 166 name, 167 discovery, 168 169 // Obtains a signed token for one of the personas above. 170 async token(persona) { 171 if (!Object.hasOwn(PERSONAS, persona)) throw new Error(`unknown persona ${persona}`); 172 const res = await fetch(`${issuer}/token`, { 173 method: 'POST', 174 headers: { 'content-type': 'application/x-www-form-urlencoded' }, 175 body: new URLSearchParams({ 176 grant_type: 'client_credentials', 177 client_id: 'conductor', 178 client_secret: 'secret', 179 scope: persona, 180 }), 181 }); 182 if (!res.ok) throw new Error(`token request failed: ${res.status} ${await res.text()}`); 183 return (await res.json()).access_token; 184 }, 185 186 // A session cookie for a persona, as the browser would carry after 187 // signing in. The conductor accepts no other credential. 188 async headers(persona) { 189 return { cookie: `${SESSION_COOKIE}=${await this.token(persona)}` }; 190 }, 191 192 // Completes the authorization code flow for a URL the conductor built, 193 // by submitting the provider's login form with the given claims. Returns 194 // the redirect the provider hands back to the callback. 195 async signIn(authorizationUrl, { username = 'alice', claims = {} } = {}) { 196 const body = new URLSearchParams({ username }); 197 if (Object.keys(claims).length > 0) body.set('claims', JSON.stringify(claims)); 198 199 const res = await fetch(authorizationUrl, { 200 method: 'POST', 201 headers: { 'content-type': 'application/x-www-form-urlencoded' }, 202 body, 203 redirect: 'manual', 204 }); 205 if (res.status !== 302) throw new Error(`provider login did not redirect: ${res.status}`); 206 return res.headers.get('location'); 207 }, 208 209 async stop() { 210 await execFileAsync('docker', ['rm', '-f', name], { timeout: 60000 }).catch(() => {}); 211 }, 212 }; 213 } 214 215 // A token signed by a key the provider does not publish, for checking that 216 // a forged signature is refused. 217 export async function forgedToken(issuer, claims = {}) { 218 const jose = await import('jose'); 219 const { privateKey } = await jose.generateKeyPair('RS256'); 220 return new jose.SignJWT({ 221 preferred_username: 'mallory', 222 realm_access: { roles: ['conductor-admin'] }, 223 ...claims, 224 }) 225 .setProtectedHeader({ alg: 'RS256', kid: 'forged' }) 226 .setSubject('mallory-sub') 227 .setIssuer(issuer) 228 .setAudience('conductor') 229 .setIssuedAt() 230 .setExpirationTime('1h') 231 .sign(privateKey); 232 }