feat(portal): real OCIS storage data via refresh-token service auth

The Storage page + endpoint landed earlier but had no working OCIS
backend credential. OCIS has no service-account/client-credentials grant
and trusts a single issuer, and basic auth resolves no user in our
external-IdP setup — so authenticate OcisClient via an OIDC
refresh-token bootstrap instead:

- One-time headless login of svc-platform-api against the ocis provider
  (public client ocis-web, issuer .../o/ocis/) yields a refresh token,
  persisted in Mongo (ocis_credentials) and rotated on every use.
- OcisClient mints access tokens with the refresh_token grant; the
  service user holds the OCIS admin role (OCIS_ADMIN_USER_ID) so
  libregraph ListAllDrives works.
- scripts/bootstrap-ocis.mjs re-runs the bootstrap if the token lapses.
- Dashboard Plan card gains a storage capacity bar beside seats;
  hidden when storage is unavailable.
- compose + .env.example: OCIS service OIDC env and admin user id.
- docs/NEXT-STEPS: document the mechanism and the dead-end alternatives.
This commit is contained in:
Ronni Baslund
2026-05-31 21:29:17 +02:00
parent 559348f6bc
commit f8618b2bbc
8 changed files with 335 additions and 60 deletions
@@ -0,0 +1,145 @@
// 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)
})