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:
@@ -1,10 +1,16 @@
|
||||
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 { OcisClient } from './ocis.client.js'
|
||||
import { StalwartClient } from './stalwart.client.js'
|
||||
import { StripeClient } from './stripe.client.js'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// OcisClient persists the rotating OCIS service refresh token here.
|
||||
MongooseModule.forFeature([{ name: OcisCredential.name, schema: OcisCredentialSchema }]),
|
||||
],
|
||||
providers: [AuthentikClient, StalwartClient, OcisClient, StripeClient],
|
||||
exports: [AuthentikClient, StalwartClient, OcisClient, StripeClient],
|
||||
})
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
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
|
||||
// integers; `total` of 0 means "unlimited" in OCIS (the default in dev).
|
||||
@@ -27,69 +30,94 @@ export interface OcisUser {
|
||||
mail?: string
|
||||
}
|
||||
|
||||
// OCIS integration. Provisioning (ensureSpace/deleteSpace) is still stubbed —
|
||||
// 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
|
||||
// page via the libregraph /graph/v1.0 API.
|
||||
const CREDENTIAL_KEY = 'ocis-svc'
|
||||
|
||||
// OCIS integration. Provisioning (ensureSpace/deleteSpace) is still stubbed.
|
||||
// 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
|
||||
// backend access (ownCloud devs: "one needs to go through OIDC authentication
|
||||
// to obtain an access token"), and it trusts exactly one issuer. So we run an
|
||||
// OIDC Resource-Owner-Password grant against the SAME Authentik provider OCIS
|
||||
// trusts (client `ocis-web`), as a dedicated service user that holds the OCIS
|
||||
// admin role (required to list all drives). The short-lived token is cached and
|
||||
// refreshed in memory. Basic auth (PROXY_ENABLE_BASIC_AUTH) doesn't resolve the
|
||||
// IDM admin in our external-IdP setup, hence this route.
|
||||
// Auth is the hard part. OCIS has no backend service-account/client-credentials
|
||||
// grant and trusts exactly one issuer; basic auth doesn't resolve a user in our
|
||||
// external-IdP setup. The only mechanism that works without reconfiguring SSO is
|
||||
// a refresh-token bootstrap: a one-time interactive OIDC login of the service
|
||||
// user (svc-platform-api) against the SAME provider OCIS trusts (`ocis-web`,
|
||||
// per-provider issuer .../o/ocis/) yields a refresh token. We persist it and
|
||||
// mint access tokens with the refresh_token grant from then on. Authentik
|
||||
// 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()
|
||||
export class OcisClient {
|
||||
private readonly logger = new Logger(OcisClient.name)
|
||||
private readonly base: string
|
||||
private readonly tokenUrl?: string
|
||||
private readonly clientId?: string
|
||||
private readonly clientSecret?: string
|
||||
private readonly username?: string
|
||||
private readonly password?: string
|
||||
private readonly scope: string
|
||||
|
||||
// In-memory token cache. Tokens live minutes; we re-grant when within the
|
||||
// skew window. Never persisted — same lifecycle as the process.
|
||||
private token?: string
|
||||
private tokenExpiresAt = 0
|
||||
// In-memory access-token cache (tokens live minutes). The refresh token lives
|
||||
// in Mongo because it rotates and must survive restarts.
|
||||
private accessToken?: string
|
||||
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.tokenUrl = config.get<string>('OCIS_OIDC_TOKEN_URL') || undefined
|
||||
this.clientId = config.get<string>('OCIS_OIDC_CLIENT_ID') || undefined
|
||||
this.clientSecret = config.get<string>('OCIS_OIDC_CLIENT_SECRET') || undefined
|
||||
this.username = config.get<string>('OCIS_SVC_USERNAME') || undefined
|
||||
this.password = config.get<string>('OCIS_SVC_PASSWORD') || undefined
|
||||
this.scope = config.get<string>('OCIS_OIDC_SCOPE') || 'openid profile email'
|
||||
// offline_access is what makes Authentik issue a refresh token on bootstrap.
|
||||
this.scope = config.get<string>('OCIS_OIDC_SCOPE') || 'openid profile email offline_access'
|
||||
}
|
||||
|
||||
// True once we have everything needed to mint a token. When false the read
|
||||
// methods short-circuit so the Storage page renders an "unavailable" state
|
||||
// instead of erroring.
|
||||
// Static config present. Whether we actually have a bootstrapped refresh token
|
||||
// is determined per-call (DB lookup) — a missing token surfaces as the page's
|
||||
// "unavailable" state, not a startup failure.
|
||||
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
|
||||
// until ~30s before expiry. The resulting token's issuer matches
|
||||
// OCIS_OIDC_ISSUER and its preferred_username maps to the service user.
|
||||
private async getToken(): Promise<string> {
|
||||
if (this.token && Date.now() < this.tokenExpiresAt) return this.token
|
||||
// Seed/replace the refresh token (called by the one-time bootstrap).
|
||||
async setRefreshToken(refreshToken: string): Promise<void> {
|
||||
await this.credModel.updateOne(
|
||||
{ key: CREDENTIAL_KEY },
|
||||
{ $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({
|
||||
grant_type: 'password',
|
||||
grant_type: 'refresh_token',
|
||||
client_id: this.clientId!,
|
||||
username: this.username!,
|
||||
password: this.password!,
|
||||
refresh_token: refreshToken,
|
||||
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!, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
||||
@@ -97,13 +125,27 @@ export class OcisClient {
|
||||
})
|
||||
if (!res.ok) {
|
||||
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 }
|
||||
if (!json.access_token) throw new Error('OCIS token grant returned no access_token')
|
||||
this.token = json.access_token
|
||||
this.tokenExpiresAt = Date.now() + Math.max(0, (json.expires_in ?? 300) - 30) * 1000
|
||||
return this.token
|
||||
const json = (await res.json()) as {
|
||||
access_token?: string
|
||||
expires_in?: number
|
||||
refresh_token?: string
|
||||
}
|
||||
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> {
|
||||
@@ -112,11 +154,10 @@ export class OcisClient {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||
})
|
||||
if (!res.ok) {
|
||||
// A 401 likely means a stale cached token; drop it so the next call
|
||||
// re-grants. (One retry is enough; callers degrade gracefully on error.)
|
||||
// Drop a possibly-stale access token so the next call re-refreshes.
|
||||
if (res.status === 401) {
|
||||
this.token = undefined
|
||||
this.tokenExpiresAt = 0
|
||||
this.accessToken = undefined
|
||||
this.accessExpiresAt = 0
|
||||
}
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new Error(`OCIS GET ${path} → ${res.status}: ${body.slice(0, 200)}`)
|
||||
@@ -145,7 +186,6 @@ export class OcisClient {
|
||||
// ── Provisioning (stubbed) ────────────────────────────────────────────────
|
||||
// Real implementation needs POST /graph/v1.0/drives { name, driveType:
|
||||
// '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 }> {
|
||||
this.logger.warn(`OCIS space provisioning is stubbed — would create space for "${slug}" at ${this.base}`)
|
||||
return { id: `stub-${slug}` }
|
||||
|
||||
Reference in New Issue
Block a user