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