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:
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user