f8618b2bbc
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.
146 lines
6.1 KiB
JavaScript
146 lines
6.1 KiB
JavaScript
// 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)
|
|
})
|