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