// One-time bootstrap: headless OIDC login of the OCIS service user against the // ocis provider (public client `ocis-web`) to obtain a refresh token, then // persist it into Mongo (ocis_credentials). The running platform-api then mints // access tokens with the refresh_token grant (see OcisClient). Re-run this if // the refresh token is ever revoked/expired. // // Run inside the platform-api container (it has the mkcert CA + Mongo access): // docker compose exec platform-api node /app/scripts/bootstrap-ocis.mjs // Requires env: AUTHENTIK_API_URL, OCIS_SVC_USERNAME, OCIS_SVC_PASSWORD, MONGODB_URI. import crypto from 'node:crypto' import mongoose from 'mongoose' const API = process.env.AUTHENTIK_API_URL // https://auth.dezky.local/api/v3 const ROOT = API.replace('/api/v3', '') const USER = process.env.OCIS_SVC_USERNAME const PASS = process.env.OCIS_SVC_PASSWORD const CLIENT_ID = 'ocis-web' const REDIRECT = 'https://files.dezky.local/oidc-callback' const SCOPE = 'openid profile email offline_access' const b64url = (b) => b.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') const verifier = b64url(crypto.randomBytes(32)) const challenge = b64url(crypto.createHash('sha256').update(verifier).digest()) const state = b64url(crypto.randomBytes(8)) // --- tiny cookie jar --- const jar = new Map() function store(res) { for (const c of res.headers.getSetCookie?.() ?? []) { const [pair] = c.split(';') const i = pair.indexOf('=') jar.set(pair.slice(0, i).trim(), pair.slice(i + 1).trim()) } } const cookieHeader = () => [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ') const csrf = () => jar.get('authentik_csrf') async function main() { const authzQuery = new URLSearchParams({ response_type: 'code', client_id: CLIENT_ID, redirect_uri: REDIRECT, scope: SCOPE, state, code_challenge: challenge, code_challenge_method: 'S256', }).toString() // 1) Hit authorize → 302 into the authentication flow; capture session cookies // AND the flow's `next` query (so the executor resumes THIS authorize plan, // not the brand's default landing app). let res = await fetch(`${ROOT}/application/o/authorize/?${authzQuery}`, { redirect: 'manual' }) store(res) const loc1 = res.headers.get('location') || '' console.log('authorize ->', res.status, loc1.slice(0, 80)) // The executor takes the flow URL's whole querystring wrapped in a single // `query` param — that's how `next` (the authorize continuation) is carried. const flowQuery = loc1.includes('?') ? loc1.slice(loc1.indexOf('?') + 1) : `next=${encodeURIComponent('/application/o/authorize/?' + authzQuery)}` // 2) Run the authentication flow via the executor API (identification → password). const exec = `${ROOT}/api/v3/flows/executor/default-authentication-flow/?query=${encodeURIComponent(flowQuery)}` const hdr = () => ({ 'Content-Type': 'application/json', Accept: 'application/json', Cookie: cookieHeader(), 'X-Authentik-CSRF': csrf() ?? '', Referer: `${ROOT}/`, }) // Unified flow driver: Authentik mixes JSON stages (POST answers) with // HTTP redirects (xak-flow-redirect / 302). Loop until we land on the // redirect_uri with a ?code=. const abs = (u) => (u.startsWith('http') ? u : ROOT + u) let url = exec let method = 'GET' let body = null let code for (let i = 0; i < 14 && !code; i++) { res = await fetch(url, { method, headers: hdr(), body, redirect: 'manual' }) store(res) if (res.status >= 300 && res.status < 400) { const loc = res.headers.get('location') || '' console.log(' redirect ->', loc.slice(0, 80)) if (loc.startsWith(REDIRECT)) { code = new URL(loc).searchParams.get('code'); break } url = abs(loc); method = 'GET'; body = null continue } const stage = await res.json().catch(() => ({})) console.log(' stage:', stage.component, stage.response_errors ? JSON.stringify(stage.response_errors).slice(0, 120) : '') switch (stage.component) { case 'ak-stage-identification': { const payload = { uid_field: USER } if (stage.password_fields) payload.password = PASS method = 'POST'; body = JSON.stringify(payload); url = exec; break } case 'ak-stage-password': method = 'POST'; body = JSON.stringify({ password: PASS }); url = exec; break case 'xak-flow-redirect': { const to = stage.to || '' if (to.startsWith(REDIRECT)) { code = new URL(to).searchParams.get('code'); break } url = abs(to); method = 'GET'; body = null; break } default: throw new Error('unexpected stage: ' + JSON.stringify(stage).slice(0, 300)) } } if (!code) throw new Error('no authorization code captured') console.log('CODE captured') // 4) Exchange code → tokens. const tokenBody = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT, client_id: CLIENT_ID, code_verifier: verifier, }) res = await fetch(`${ROOT}/application/o/token/`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body: tokenBody, }) const tok = await res.json() console.log('token ->', res.status, tok.error || `refresh_token:${tok.refresh_token ? 'yes' : 'no'} scope:${tok.scope}`) if (!tok.refresh_token) throw new Error('no refresh_token: ' + JSON.stringify(tok).slice(0, 200)) // Sanity: decode access token issuer + identity. const claims = JSON.parse(Buffer.from(tok.access_token.split('.')[1], 'base64').toString()) console.log('iss:', claims.iss, '| preferred_username:', claims.preferred_username) // 5) Persist refresh token. await mongoose.connect(process.env.MONGODB_URI) await mongoose.connection.collection('ocis_credentials').updateOne( { key: 'ocis-svc' }, { $set: { key: 'ocis-svc', refreshToken: tok.refresh_token, updatedAt: new Date() }, $setOnInsert: { createdAt: new Date() } }, { upsert: true }, ) await mongoose.disconnect() console.log('PERSISTED refresh token to ocis_credentials') } main().catch((e) => { console.log('BOOTSTRAP ERROR:', String(e).slice(0, 400)) process.exit(1) })