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
+5
View File
@@ -58,6 +58,11 @@ OCIS_ADMIN_PASSWORD=changeme_use_openssl_rand
# (required to list all drives). See docs/NEXT-STEPS.md. # (required to list all drives). See docs/NEXT-STEPS.md.
OCIS_SVC_USERNAME=svc-platform-api OCIS_SVC_USERNAME=svc-platform-api
OCIS_SVC_PASSWORD=changeme_use_openssl_rand OCIS_SVC_PASSWORD=changeme_use_openssl_rand
# OCIS account UUID of the service user, used to grant it the OCIS admin role at
# startup (required for libregraph ListAllDrives). Populate after the OCIS
# bootstrap autoprovisions the account (see docs/NEXT-STEPS.md). Leave empty
# until then.
OCIS_ADMIN_USER_ID=
# ──────────────────────────────────────── # ────────────────────────────────────────
# Collabora # Collabora
+37 -4
View File
@@ -3,10 +3,11 @@
// `AdminDashboard`, but the data is real: workspace identity, seats, spend, // `AdminDashboard`, but the data is real: workspace identity, seats, spend,
// plan and recent admin events all come from /api/me + /api/tenants/:slug/*. // plan and recent admin events all come from /api/me + /api/tenants/:slug/*.
// //
// Sections without a real backend source yet (storage usage, mail-flow health, // Storage usage is real now too (OCIS libregraph via /tenants/:slug/storage) —
// "open issues" like DMARC/failed-login heuristics) were removed rather than // shown as a second capacity bar in the Plan card. Sections still without a
// faked — they return when their backends (OCIS metrics, Stalwart metrics, a // backend (mail-flow health, "open issues" like DMARC/failed-login heuristics)
// domain-health checker) exist. // stay removed rather than faked until Stalwart metrics / a domain-health
// checker exist.
import type { IconName } from '~/components/UiIcon.vue' import type { IconName } from '~/components/UiIcon.vue'
import type { AuditEventDoc, TenantUserDoc } from '~/types/workspace' import type { AuditEventDoc, TenantUserDoc } from '~/types/workspace'
@@ -30,6 +31,20 @@ const { data: auditRaw } = await useFetch<AuditEventDoc[]>(
{ key: 'admin-dash-audit', default: () => [], immediate: !!slug.value, watch: [slug] }, { key: 'admin-dash-audit', default: () => [], immediate: !!slug.value, watch: [slug] },
) )
// Aggregate storage usage (OCIS) — second capacity bar in the Plan card.
interface StorageSummary {
available: boolean
usedBytes: number
quotaBytes: number
freeBytes: number
}
const { data: storage } = await useFetch<StorageSummary | null>(
() => `/api/tenants/${slug.value}/storage`,
{ key: 'admin-dash-storage', default: () => null, immediate: !!slug.value, watch: [slug] },
)
const storageAvailable = computed(() => storage.value?.available === true)
const storagePct = computed(() => percent(storage.value?.usedBytes ?? 0, storage.value?.quotaBytes ?? 0))
const seatsUsed = computed(() => (users.value ?? []).filter((u) => u.active !== false).length) const seatsUsed = computed(() => (users.value ?? []).filter((u) => u.active !== false).length)
const seatsAvailable = computed(() => Math.max(0, seatLimit.value - seatsUsed.value)) const seatsAvailable = computed(() => Math.max(0, seatLimit.value - seatsUsed.value))
const seatPct = computed(() => const seatPct = computed(() =>
@@ -149,6 +164,7 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal.val
</div> </div>
<div class="progress-block"> <div class="progress-block">
<div class="bar-label">Seats</div>
<div class="progress-bar"><span :style="{ width: `${seatPct}%` }" /></div> <div class="progress-bar"><span :style="{ width: `${seatPct}%` }" /></div>
<div class="progress-legend"> <div class="progress-legend">
<span>{{ seatsUsed }} active</span> <span>{{ seatsUsed }} active</span>
@@ -156,6 +172,15 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal.val
</div> </div>
</div> </div>
<div v-if="storageAvailable" class="progress-block">
<div class="bar-label">Storage</div>
<div class="progress-bar"><span :style="{ width: `${storagePct}%` }" /></div>
<div class="progress-legend">
<span>{{ formatBytes(storage!.usedBytes) }} used</span>
<span>{{ formatBytes(storage!.freeBytes) }} free</span>
</div>
</div>
<div class="seats-cta"> <div class="seats-cta">
<div class="seats-cta-text"> <div class="seats-cta-text">
Approaching limit? You can add seats in single increments billed prorated. Approaching limit? You can add seats in single increments billed prorated.
@@ -335,6 +360,14 @@ const prorated = computed(() => Math.round(monthly.value * (daysUntilRenewal.val
/* License progress */ /* License progress */
.progress-block { margin-bottom: 16px; } .progress-block { margin-bottom: 16px; }
.bar-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-mute);
margin-bottom: 8px;
}
.progress-bar { .progress-bar {
height: 8px; height: 8px;
background: var(--bg); background: var(--bg);
+23 -4
View File
@@ -124,10 +124,29 @@ Need a minimal JMAP client that wraps `Domain/set` (create), `Domain/get`
via the persistent admin's bearer token from the OAuth flow we already use via the persistent admin's bearer token from the OAuth flow we already use
for the web UI. for the web UI.
**OCIS (libregraph)**`POST /graph/v1.0/drives` with body **OCIS (libregraph)**space *provisioning* is still stubbed:
`{ "name": "<slug>", "driveType": "project" }`. Needs service-to-service `POST /graph/v1.0/drives` with body `{ "name": "<slug>", "driveType":
auth: either an OIDC client_credentials grant (requires registering a new "project" }` to create a tenant's project space, then assign it.
Authentik provider for the worker) or the IDM admin user's bearer token.
**OCIS read auth (done — powers the customer-admin Storage page).** OCIS has
*no* backend service-account/client-credentials grant and trusts exactly one
issuer, and basic auth doesn't resolve a user in our external-IdP setup. The
working mechanism is a **refresh-token bootstrap**:
1. A dedicated Authentik user `svc-platform-api` (with an email — OCIS
autoprovision rejects empty emails) logs in **once** against the *ocis*
provider (public client `ocis-web`, per-provider issuer `.../o/ocis/` — the
one OCIS trusts). Run it headlessly:
`docker compose exec platform-api node /app/scripts/bootstrap-ocis.mjs`.
The refresh token is persisted in Mongo (`ocis_credentials`).
2. `OcisClient` mints access tokens with the `refresh_token` grant and persists
the rotated token each call (Authentik rotates on every use).
3. The svc user needs the OCIS **admin** role for `ListAllDrives` — granted via
`OCIS_ADMIN_USER_ID=<svc OCIS account UUID>` on the ocis service.
Note: the "global" issuer mode is **not** an option — its issuer is the
Authentik root, which has no `.well-known/openid-configuration`, so OCIS can't
validate tokens against it.
### Authentik API examples (for the eventual user-creation flow) ### Authentik API examples (for the eventual user-creation flow)
@@ -325,7 +325,12 @@ services:
PROXY_AUTOPROVISION_ACCOUNTS: "true" PROXY_AUTOPROVISION_ACCOUNTS: "true"
PROXY_USER_OIDC_CLAIM: preferred_username PROXY_USER_OIDC_CLAIM: preferred_username
PROXY_USER_CS3_CLAIM: username PROXY_USER_CS3_CLAIM: username
OCIS_ADMIN_USER_ID: "" # Grant the OCIS admin role to the platform-api service user (autoprovisioned
# OCIS account of svc-platform-api). Admin is required for libregraph
# ListAllDrives, which powers the customer-admin Storage page. The UUID is
# the svc user's OCIS account id; stable as long as the OCIS data volume
# persists. Empty in fresh setups until the OCIS bootstrap has run.
OCIS_ADMIN_USER_ID: ${OCIS_ADMIN_USER_ID:-}
IDM_CREATE_DEMO_USERS: "false" IDM_CREATE_DEMO_USERS: "false"
IDM_ADMIN_PASSWORD: ${OCIS_ADMIN_PASSWORD} IDM_ADMIN_PASSWORD: ${OCIS_ADMIN_PASSWORD}
STORAGE_USERS_DRIVER: ocis # Local filesystem in dev STORAGE_USERS_DRIVER: ocis # Local filesystem in dev
@@ -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)
})
@@ -1,10 +1,16 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose'
import { OcisCredential, OcisCredentialSchema } from '../schemas/ocis-credential.schema.js'
import { AuthentikClient } from './authentik.client.js' import { AuthentikClient } from './authentik.client.js'
import { OcisClient } from './ocis.client.js' import { OcisClient } from './ocis.client.js'
import { StalwartClient } from './stalwart.client.js' import { StalwartClient } from './stalwart.client.js'
import { StripeClient } from './stripe.client.js' import { StripeClient } from './stripe.client.js'
@Module({ @Module({
imports: [
// OcisClient persists the rotating OCIS service refresh token here.
MongooseModule.forFeature([{ name: OcisCredential.name, schema: OcisCredentialSchema }]),
],
providers: [AuthentikClient, StalwartClient, OcisClient, StripeClient], providers: [AuthentikClient, StalwartClient, OcisClient, StripeClient],
exports: [AuthentikClient, StalwartClient, OcisClient, StripeClient], exports: [AuthentikClient, StalwartClient, OcisClient, StripeClient],
}) })
@@ -1,5 +1,8 @@
import { Injectable, Logger } from '@nestjs/common' import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config' import { ConfigService } from '@nestjs/config'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { OcisCredential, OcisCredentialDocument } from '../schemas/ocis-credential.schema.js'
// A libregraph quota object as returned on each drive. All byte counts are // A libregraph quota object as returned on each drive. All byte counts are
// integers; `total` of 0 means "unlimited" in OCIS (the default in dev). // integers; `total` of 0 means "unlimited" in OCIS (the default in dev).
@@ -27,69 +30,94 @@ export interface OcisUser {
mail?: string mail?: string
} }
// OCIS integration. Provisioning (ensureSpace/deleteSpace) is still stubbed — const CREDENTIAL_KEY = 'ocis-svc'
// it needs the project-space create call (see docs/NEXT-STEPS.md). The READ
// layer below is real: it lists per-drive quota for the customer-admin Storage // OCIS integration. Provisioning (ensureSpace/deleteSpace) is still stubbed.
// page via the libregraph /graph/v1.0 API. // The READ layer is real: it lists per-drive quota for the customer-admin
// Storage page via the libregraph /graph/v1.0 API.
// //
// Auth: OCIS has no built-in service-account/client-credentials grant for // Auth is the hard part. OCIS has no backend service-account/client-credentials
// backend access (ownCloud devs: "one needs to go through OIDC authentication // grant and trusts exactly one issuer; basic auth doesn't resolve a user in our
// to obtain an access token"), and it trusts exactly one issuer. So we run an // external-IdP setup. The only mechanism that works without reconfiguring SSO is
// OIDC Resource-Owner-Password grant against the SAME Authentik provider OCIS // a refresh-token bootstrap: a one-time interactive OIDC login of the service
// trusts (client `ocis-web`), as a dedicated service user that holds the OCIS // user (svc-platform-api) against the SAME provider OCIS trusts (`ocis-web`,
// admin role (required to list all drives). The short-lived token is cached and // per-provider issuer .../o/ocis/) yields a refresh token. We persist it and
// refreshed in memory. Basic auth (PROXY_ENABLE_BASIC_AUTH) doesn't resolve the // mint access tokens with the refresh_token grant from then on. Authentik
// IDM admin in our external-IdP setup, hence this route. // rotates the refresh token on every use, so we persist the new one each time.
// The service user must hold the OCIS admin role (required to list all drives).
@Injectable() @Injectable()
export class OcisClient { export class OcisClient {
private readonly logger = new Logger(OcisClient.name) private readonly logger = new Logger(OcisClient.name)
private readonly base: string private readonly base: string
private readonly tokenUrl?: string private readonly tokenUrl?: string
private readonly clientId?: string private readonly clientId?: string
private readonly clientSecret?: string
private readonly username?: string
private readonly password?: string
private readonly scope: string private readonly scope: string
// In-memory token cache. Tokens live minutes; we re-grant when within the // In-memory access-token cache (tokens live minutes). The refresh token lives
// skew window. Never persisted — same lifecycle as the process. // in Mongo because it rotates and must survive restarts.
private token?: string private accessToken?: string
private tokenExpiresAt = 0 private accessExpiresAt = 0
// Single-flight guard so concurrent storage requests don't race the rotating
// refresh token (each refresh invalidates the previous one).
private refreshing?: Promise<string>
constructor(config: ConfigService) { constructor(
config: ConfigService,
@InjectModel(OcisCredential.name) private readonly credModel: Model<OcisCredentialDocument>,
) {
this.base = config.getOrThrow<string>('OCIS_API_URL') this.base = config.getOrThrow<string>('OCIS_API_URL')
this.tokenUrl = config.get<string>('OCIS_OIDC_TOKEN_URL') || undefined this.tokenUrl = config.get<string>('OCIS_OIDC_TOKEN_URL') || undefined
this.clientId = config.get<string>('OCIS_OIDC_CLIENT_ID') || undefined this.clientId = config.get<string>('OCIS_OIDC_CLIENT_ID') || undefined
this.clientSecret = config.get<string>('OCIS_OIDC_CLIENT_SECRET') || undefined // offline_access is what makes Authentik issue a refresh token on bootstrap.
this.username = config.get<string>('OCIS_SVC_USERNAME') || undefined this.scope = config.get<string>('OCIS_OIDC_SCOPE') || 'openid profile email offline_access'
this.password = config.get<string>('OCIS_SVC_PASSWORD') || undefined
this.scope = config.get<string>('OCIS_OIDC_SCOPE') || 'openid profile email'
} }
// True once we have everything needed to mint a token. When false the read // Static config present. Whether we actually have a bootstrapped refresh token
// methods short-circuit so the Storage page renders an "unavailable" state // is determined per-call (DB lookup) — a missing token surfaces as the page's
// instead of erroring. // "unavailable" state, not a startup failure.
get configured(): boolean { get configured(): boolean {
return !!(this.tokenUrl && this.clientId && this.username && this.password) return !!(this.tokenUrl && this.clientId)
} }
// ROPC (password) grant against the ocis provider's token endpoint. Cached // Seed/replace the refresh token (called by the one-time bootstrap).
// until ~30s before expiry. The resulting token's issuer matches async setRefreshToken(refreshToken: string): Promise<void> {
// OCIS_OIDC_ISSUER and its preferred_username maps to the service user. await this.credModel.updateOne(
private async getToken(): Promise<string> { { key: CREDENTIAL_KEY },
if (this.token && Date.now() < this.tokenExpiresAt) return this.token { $set: { refreshToken } },
{ upsert: true },
)
this.accessToken = undefined
this.accessExpiresAt = 0
}
private async loadRefreshToken(): Promise<string> {
const doc = await this.credModel.findOne({ key: CREDENTIAL_KEY }).lean().exec()
if (!doc?.refreshToken) {
throw new Error('OCIS service refresh token not bootstrapped — run the OCIS bootstrap')
}
return doc.refreshToken
}
private async getToken(): Promise<string> {
if (this.accessToken && Date.now() < this.accessExpiresAt) return this.accessToken
if (this.refreshing) return this.refreshing
this.refreshing = this.refreshAccessToken().finally(() => {
this.refreshing = undefined
})
return this.refreshing
}
// Exchange the stored refresh token for a fresh access token, persisting the
// rotated refresh token Authentik hands back. Public client (ocis-web) → no
// client secret. The resulting token's issuer matches OCIS_OIDC_ISSUER.
private async refreshAccessToken(): Promise<string> {
const refreshToken = await this.loadRefreshToken()
const body = new URLSearchParams({ const body = new URLSearchParams({
grant_type: 'password', grant_type: 'refresh_token',
client_id: this.clientId!, client_id: this.clientId!,
username: this.username!, refresh_token: refreshToken,
password: this.password!,
scope: this.scope, scope: this.scope,
}) })
// Public clients (ocis-web) omit the secret; included only if configured
// (e.g. a confidential service provider).
if (this.clientSecret) body.set('client_secret', this.clientSecret)
const res = await fetch(this.tokenUrl!, { const res = await fetch(this.tokenUrl!, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
@@ -97,13 +125,27 @@ export class OcisClient {
}) })
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => '') const text = await res.text().catch(() => '')
throw new Error(`OCIS token grant → ${res.status}: ${text.slice(0, 200)}`) // A revoked/expired refresh token means a human must re-bootstrap.
this.logger.warn(
`OCIS token refresh → ${res.status}: ${text.slice(0, 160)} — re-bootstrap the OCIS service login if this persists`,
)
throw new Error(`OCIS token refresh failed (${res.status})`)
} }
const json = (await res.json()) as { access_token?: string; expires_in?: number } const json = (await res.json()) as {
if (!json.access_token) throw new Error('OCIS token grant returned no access_token') access_token?: string
this.token = json.access_token expires_in?: number
this.tokenExpiresAt = Date.now() + Math.max(0, (json.expires_in ?? 300) - 30) * 1000 refresh_token?: string
return this.token }
if (!json.access_token) throw new Error('OCIS token refresh returned no access_token')
// Persist the rotated refresh token (Authentik rotates on every use).
if (json.refresh_token && json.refresh_token !== refreshToken) {
await this.credModel
.updateOne({ key: CREDENTIAL_KEY }, { $set: { refreshToken: json.refresh_token } })
.exec()
}
this.accessToken = json.access_token
this.accessExpiresAt = Date.now() + Math.max(0, (json.expires_in ?? 300) - 30) * 1000
return this.accessToken
} }
private async request<T>(path: string): Promise<T> { private async request<T>(path: string): Promise<T> {
@@ -112,11 +154,10 @@ export class OcisClient {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
}) })
if (!res.ok) { if (!res.ok) {
// A 401 likely means a stale cached token; drop it so the next call // Drop a possibly-stale access token so the next call re-refreshes.
// re-grants. (One retry is enough; callers degrade gracefully on error.)
if (res.status === 401) { if (res.status === 401) {
this.token = undefined this.accessToken = undefined
this.tokenExpiresAt = 0 this.accessExpiresAt = 0
} }
const body = await res.text().catch(() => '') const body = await res.text().catch(() => '')
throw new Error(`OCIS GET ${path}${res.status}: ${body.slice(0, 200)}`) throw new Error(`OCIS GET ${path}${res.status}: ${body.slice(0, 200)}`)
@@ -145,7 +186,6 @@ export class OcisClient {
// ── Provisioning (stubbed) ──────────────────────────────────────────────── // ── Provisioning (stubbed) ────────────────────────────────────────────────
// Real implementation needs POST /graph/v1.0/drives { name, driveType: // Real implementation needs POST /graph/v1.0/drives { name, driveType:
// 'project' } to create a space and assign it to the tenant's group / users. // 'project' } to create a space and assign it to the tenant's group / users.
// Phase 4 ships the orchestration; this hooks up in a follow-up.
async ensureSpace(slug: string): Promise<{ id: string }> { async ensureSpace(slug: string): Promise<{ id: string }> {
this.logger.warn(`OCIS space provisioning is stubbed — would create space for "${slug}" at ${this.base}`) this.logger.warn(`OCIS space provisioning is stubbed — would create space for "${slug}" at ${this.base}`)
return { id: `stub-${slug}` } return { id: `stub-${slug}` }
@@ -0,0 +1,22 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument } from 'mongoose'
export type OcisCredentialDocument = HydratedDocument<OcisCredential>
// Persisted OIDC refresh token for the OCIS service user (svc-platform-api).
// OCIS has no backend service-account grant and trusts a single issuer, so we
// obtain a token via a one-time interactive OIDC login of the service user
// against the ocis provider, then keep minting access tokens with the
// refresh_token grant. Authentik ROTATES refresh tokens on every use, so the
// newest one must be persisted each time — that's the whole reason this lives
// in Mongo rather than memory/env. Single-row, keyed by `key`.
@Schema({ collection: 'ocis_credentials', timestamps: true })
export class OcisCredential {
@Prop({ required: true, unique: true, index: true, default: 'ocis-svc' })
key!: string
@Prop({ required: true })
refreshToken!: string
}
export const OcisCredentialSchema = SchemaFactory.createForClass(OcisCredential)