Source

auth.js

/**
 * @module auth
 */

import crypto from 'node:crypto'

import debug from 'debug'

import { getToken } from './oauth.js'

export class AuthenticationError extends Error {
  constructor (message) {
    super(message)
    this.httpCode = 401
  }
}

const unauthorized = 'auth/unauthorized'

const EXCHANGE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange'
const ID_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id_token'

const isExpired = expiresAt => Date.now() >= expiresAt - 60000

const dbg = debug('scenid:sdk:auth')

/**
 * @typedef {Object} ServiceKey
 * @property {string} client_id - OAuth 2.0 client identifier.
 * @property {string} client_secret - OAuth 2.0 client secret.
 * @property {string} token_uri - Token endpoint URL (e.g. `https://auth.example.com/token`).
 * @property {string} [auth_uri] - Authorization endpoint URL, required for `beginFlow`.
 * @property {string} [jwks_uri] - JWKS endpoint URL, required for `verify`.
 * @property {string} [issuer] - Expected issuer claim, required for `verify`.
 * @property {string} [cloud_stack] - Override the base domain used to resolve service URLs
 *   (e.g. `example.com`). When omitted the SDK derives the base domain from `issuer`.
 * @property {boolean} [ssl=true] - Set to `false` to use plain HTTP (local dev only).
 */

/**
 * @typedef {Object} FlowResult
 * @property {string} idToken - The raw OIDC ID token returned by the authorization server.
 * @property {string|undefined} refreshToken - The refresh token, if the IdP issued one.
 * @property {Object} user - Decoded payload of the ID token (claims such as `sub`, `email`, `tenantId`).
 */

/**
 * Handles the OAuth 2.0 / OIDC authentication flows and token lifecycle.
 *
 * Obtained via `sdk.Auth()` after calling `initScenid`. Do not instantiate directly.
 *
 * @example
 * // 1. Start the login redirect
 * const url = sdk.Auth().beginFlow({ redirectUri: 'https://myapp.com/callback' })
 * res.redirect(url)
 *
 * // 2. Exchange the code after the redirect
 * const { idToken, user } = await sdk.Auth().completeFlow(code, 'https://myapp.com/callback')
 *
 * // 3. Verify a token from an incoming request
 * const payload = await sdk.Auth().verify(idToken)
 */
class Auth {
  /**
   * @param {ServiceKey} serviceKey
   */
  constructor ({ client_id, client_secret, token_uri, auth_uri, jwks_uri, issuer }) {
    this._clientId = client_id
    this._clientSecret = client_secret
    this._tokenUri = token_uri
    this._authUri = auth_uri
    this._jwksUri = jwks_uri
    this._issuer = issuer
    this._jwksCache = undefined
    this._jwksCachedAt = 0
    this._jwksTtl = 60 * 60 * 1000
    this._exchangeCache = new Map()
  }

  /**
   * Constructs the authorization URL to redirect the user to for login.
   *
   * @param {Object} options
   * @param {string} options.redirectUri - URI the authorization server redirects to after login.
   * @param {string} [options.state] - Optional opaque value forwarded in the redirect for CSRF protection.
   * @param {string} [options.scope='openid profile email'] - Space-separated list of requested scopes.
   * @returns {string} The full authorization URL to redirect the user to.
   *
   * @example
   * const url = sdk.Auth().beginFlow({
   *   redirectUri: 'https://myapp.com/callback',
   *   state: crypto.randomUUID()
   * })
   * res.redirect(url)
   */
  beginFlow ({ redirectUri, state, scope = 'openid profile email offline_access' }) {
    const url = new URL(this._authUri)
    url.searchParams.set('response_type', 'code')
    url.searchParams.set('client_id', this._clientId)
    url.searchParams.set('redirect_uri', redirectUri)
    url.searchParams.set('scope', scope)
    if (state) url.searchParams.set('state', state)

    dbg('beginFlow → %s', url.toString())

    return url.toString()
  }

  /**
   * Exchanges an authorization code for tokens and returns the ID token and decoded user claims.
   *
   * Call this in your OAuth callback handler after the authorization server redirects back
   * with a `code` query parameter.
   *
   * @param {string} code - The authorization code from the query string.
   * @param {string} redirectUri - Must match the URI used in `beginFlow`.
   * @returns {Promise<FlowResult>}
   * @throws {Error} `auth/complete-flow-failed` – token exchange returned a non-OK status.
   * @throws {Error} `auth/complete-flow-no-token` – token response contained no usable token.
   *
   * @example
   * const { idToken, user } = await sdk.Auth().completeFlow(req.query.code, REDIRECT_URI)
   * // store idToken in session, use user.sub / user.email / user.tenantId
   */
  async completeFlow (code, redirectUri) {
    dbg('completeFlow: POST %s  code=%s…', this._tokenUri, code.slice(0, 8))

    const res = await fetch(this._tokenUri, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        grant_type: 'authorization_code',
        code,
        redirect_uri: redirectUri,
        client_id: this._clientId,
        client_secret: this._clientSecret
      })
    })

    if (!res.ok) {
      const body = await res.text()
      dbg('completeFlow: ← %d %s', res.status, body)
      throw new Error(`auth/complete-flow-failed: ${res.status} ${body}`)
    }

    const { id_token, access_token, refresh_token } = await res.json()
    const token = id_token || access_token

    if (!token) throw new Error('auth/complete-flow-no-token')

    const user = this._decodePayload(token)
    dbg('completeFlow: ← 200  sub=%s  email=%s', user.sub, user.email)

    return { idToken: token, refreshToken: refresh_token, user }
  }

  /**
   * Exchanges a refresh token for a new ID token.
   *
   * @param {string} refreshToken - The refresh token obtained from `completeFlow`.
   * @returns {Promise<FlowResult>}
   * @throws {Error} `auth/refresh-flow-failed` – token refresh returned a non-OK status.
   * @throws {Error} `auth/refresh-flow-no-token` – token response contained no usable token.
   */
  async refreshFlow (refreshToken) {
    dbg('refreshFlow: POST %s', this._tokenUri)

    const res = await fetch(this._tokenUri, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
        client_id: this._clientId,
        client_secret: this._clientSecret
      })
    })

    if (!res.ok) {
      const body = await res.text()
      dbg('refreshFlow: ← %d %s', res.status, body)
      throw new Error(`auth/refresh-flow-failed: ${res.status} ${body}`)
    }

    const { id_token, access_token, refresh_token: newRefreshToken } = await res.json()
    const token = id_token || access_token

    if (!token) throw new Error('auth/refresh-flow-no-token')

    const user = this._decodePayload(token)
    dbg('refreshFlow: ← 200  sub=%s', user.sub)

    return { idToken: token, refreshToken: newRefreshToken, user }
  }

  /**
   * Verifies an ID token's issuer and expiry against the authorization server's JWKS.
   *
   * The JWKS is fetched once and cached for one hour. Use this in server-side middleware
   * to confirm an incoming token was issued by your Scenid tenant before passing it to
   * `sdk.asUser(idToken)`.
   *
   * **Note:** currently validates issuer and expiry only — signature verification
   * is not yet implemented.
   *
   * @param {string} idToken - The raw ID token to verify.
   * @returns {Promise<Object>} The decoded JWT payload (claims).
   * @throws {Error} `auth/jwks-key-not-found` – no matching key in JWKS for the token's `kid`.
   * @throws {Error} `auth/invalid-issuer` – `iss` claim does not match the configured issuer.
   * @throws {Error} `auth/token-expired` – token has passed its `exp` timestamp.
   * @throws {Error} `auth/invalid-token-format` – token could not be decoded.
   *
   * @example
   * // Express middleware
   * const payload = await sdk.Auth().verify(req.headers.authorization?.split(' ')[1])
   * req.user = payload
   */
  async verify (idToken) {
    dbg('verify: fetching JWKS from %s', this._jwksUri)

    const jwks = await this._getJwks()
    const header = this._decodeHeader(idToken)
    const jwk = jwks.keys.find(k => k.kid === header.kid)

    if (!jwk) throw new Error('auth/jwks-key-not-found')

    const [headerB64, payloadB64, sigB64] = idToken.split('.')
    const publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' })
    const valid = crypto.createVerify('RSA-SHA256')
      .update(`${headerB64}.${payloadB64}`)
      .verify(publicKey, sigB64, 'base64url')

    if (!valid) throw new Error('auth/invalid-signature')

    const payload = this._decodePayload(idToken)

    if (payload.iss !== this._issuer) throw new Error('auth/invalid-issuer')
    if (payload.exp * 1000 < Date.now()) throw new Error('auth/token-expired')

    dbg('verify: valid  sub=%s  exp=%s', payload.sub, new Date(payload.exp * 1000).toISOString())

    return payload
  }

  /**
   * Removes all cached exchanged tokens for a given user subject.
   *
   * Call this on logout so the next `asUser` call performs a fresh token exchange
   * rather than returning a stale cached token.
   *
   * @param {string} sub - The user's `sub` claim (subject identifier).
   *
   * @example
   * sdk.Auth().clearExchangeCache(session.user.sub)
   * session.destroy()
   */
  clearExchangeCache (sub) {
    for (const key of this._exchangeCache.keys()) {
      if (key.startsWith(`${sub}:`)) this._exchangeCache.delete(key)
    }
  }

  /**
   * Exchanges a user's OIDC ID token for a Scenid service access token.
   *
   * The resulting token is cached per user+client until 60 seconds before it expires.
   * This method is called automatically by `sdk.asUser(idToken)` — you rarely need to
   * call it directly.
   *
   * @param {string} idToken - The OIDC ID token obtained from `completeFlow`.
   * @returns {Promise<string>} A service-scoped access token.
   * @throws {Error} `auth/exchange-failed` – token exchange returned a non-OK status.
   */
  async exchangeToken (idToken) {
    const payload = this._decodePayload(idToken)
    const cacheKey = `${payload.sub}:${this._clientId}`

    const cached = this._exchangeCache.get(cacheKey)
    if (cached && !isExpired(cached.expiresAt)) {
      dbg('exchangeToken: cache hit  sub=%s', payload.sub)
      return cached.accessToken
    }

    dbg('exchangeToken: POST %s  sub=%s', this._tokenUri, payload.sub)
    const t0 = Date.now()

    const res = await fetch(this._tokenUri, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        grant_type: EXCHANGE_GRANT_TYPE,
        subject_token: idToken,
        subject_token_type: ID_TOKEN_TYPE,
        client_id: this._clientId,
        client_secret: this._clientSecret
      })
    })

    if (!res.ok) {
      const body = await res.text()
      dbg('exchangeToken: ← %d (%dms)  %s', res.status, Date.now() - t0, body)
      throw new Error(`auth/exchange-failed: ${res.status} ${body}`)
    }

    const { access_token, expires_in } = await res.json()
    dbg('exchangeToken: ← 200 (%dms)  expires_in=%ds', Date.now() - t0, expires_in)

    this._exchangeCache.set(cacheKey, {
      accessToken: access_token,
      expiresAt: Date.now() + expires_in * 1000
    })

    return access_token
  }

  /**
   * Acquires a machine-to-machine service token using the client credentials grant.
   *
   * The token is cached until 60 seconds before expiry. This method is called
   * automatically by `sdk.asAdmin()` — you rarely need to call it directly.
   *
   * @returns {Promise<string>} A service-scoped access token.
   */
  async getServiceToken () {
    dbg('getServiceToken: POST %s  client_id=%s', this._tokenUri, this._clientId)
    const t0 = Date.now()

    const entry = await getToken(this._clientId, this._clientSecret, this._tokenUri)

    dbg('getServiceToken: ← ok (%dms)  expires_at=%s', Date.now() - t0, new Date(entry.expiresAt).toISOString())

    return entry.accessToken
  }

  /**
   * Fetches the JWKS from the authorization server, with a 1-hour in-memory cache.
   * @returns {Promise<Object>} The JWKS document.
   * @private
   */
  async _getJwks () {
    if (this._jwksCache && Date.now() - this._jwksCachedAt < this._jwksTtl) {
      dbg('_getJwks: cache hit')
      return this._jwksCache
    }

    dbg('_getJwks: GET %s', this._jwksUri)

    const res = await fetch(this._jwksUri)
    if (!res.ok) throw new Error(`auth/jwks-fetch-failed: ${res.status}`)

    this._jwksCache = await res.json()
    this._jwksCachedAt = Date.now()

    dbg('_getJwks: loaded %d key(s)', this._jwksCache.keys?.length ?? '?')

    return this._jwksCache
  }

  /**
   * Decodes the header of a JWT without verifying its signature.
   * @param {string} token
   * @returns {Object}
   * @private
   */
  _decodeHeader (token) {
    try {
      const [headerB64] = token.split('.')
      return JSON.parse(Buffer.from(headerB64, 'base64url').toString('utf8'))
    } catch (e) {
      dbg(e.stack || e.message)
      throw new Error('auth/invalid-token-format')
    }
  }

  /**
   * Returns an Express middleware that verifies an incoming Bearer token and
   * populates `req.user` (user tokens) or `req.serviceClient` (service tokens).
   *
   * The JWKS used for verification is cached for one hour — no per-request
   * call to the auth service. Soft middleware: always calls `next()`, even when
   * no token is present or verification fails. Use gate functions to enforce access.
   *
   * @returns {Function} Express `async (req, res, next)` middleware.
   *
   * @example
   * app.use(sdk.Auth().middleware())
   *
   * app.get('/protected', async (req, res) => {
   *   const user = await checkIsUser(req)
   *   res.json({ sub: user.sub })
   * })
   */
  middleware () {
    return async (req, res, next) => {
      const authHeader = req.headers?.authorization

      if (!authHeader?.startsWith('Bearer ')) {
        next()
        return
      }

      const token = authHeader.split('Bearer ')[1]
      let payload

      try {
        payload = await this.verify(token)
      } catch (e) {
        dbg('middleware: verify failed — %s', e.message)
        next()
        return
      }

      if (payload.token_type === 'service') {
        req.serviceClient = {
          clientId: payload.sub,
          tenantId: payload.tenantId,
          scope: payload.scope,
          clientName: payload.client_name,
          isServiceClient: true
        }
      } else {
        req.user = payload
      }

      next()
    }
  }

  /**
   * Decodes the payload of a JWT without verifying its signature.
   * @param {string} token
   * @returns {Object}
   * @private
   */
  _decodePayload (token) {
    try {
      const [, payloadB64] = token.split('.')
      return JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'))
    } catch (e) {
      dbg(e.stack || e.message)
      throw new Error('auth/invalid-token-format')
    }
  }
}

export default Auth

const getPath = (obj, path) => path.split('.').reduce((acc, key) => acc?.[key], obj)

const isUser = context => context.user !== undefined
const isOwner = context => isUser(context) && context.user.owner === true
const isAdmin = context => isOwner(context) || (isUser(context) && context.user.admin === true)
const isService = context => context.serviceClient?.isServiceClient === true
const hasClaim = (context, claim) => isOwner(context) || getPath(context.user, claim) === true

export const checkIsUser = context => {
  if (!isUser(context)) throw new AuthenticationError(unauthorized)
  return context.user
}

export const checkIsOwner = context => {
  if (!isOwner(context)) throw new AuthenticationError(unauthorized)
  return context.user
}

export const checkIsAdmin = context => {
  if (!isAdmin(context)) throw new AuthenticationError(unauthorized)
  return context.user
}

export const checkIsService = context => {
  if (!isService(context)) throw new AuthenticationError(unauthorized)
  return context.serviceClient
}

export const checkHasClaim = (context, claim) => {
  if (!hasClaim(context, claim)) throw new AuthenticationError(unauthorized)
  return context.user
}

export const checkHasClaims = (context, claims) => {
  for (let i = 0; i < claims.length; i++) {
    if (hasClaim(context, claims[i])) return context.user
  }
  throw new AuthenticationError(unauthorized)
}

export const checkIsProductUser = (context, product) => {
  if (hasClaim(context, `${product}.use`) || hasClaim(context, `${product}.admin`)) {
    return context.user
  }
  throw new AuthenticationError(unauthorized)
}

export const checkIsProductAdmin = (context, product) => {
  if (!hasClaim(context, `${product}.admin`)) throw new AuthenticationError(unauthorized)
  return context.user
}

export const oneOf = async (context, guards) => {
  for (const guard of guards) {
    try {
      return await guard(context)
    } catch (e) {
      if (!(e instanceof AuthenticationError)) throw e
    }
  }
  throw new AuthenticationError(unauthorized)
}

export const hasResourcePermission = (context, allowedList = []) => {
  if (!isUser(context)) return false
  const sub = context.user.sub
  const groups = context.user.userGroups || []

  return allowedList.some(entry => {
    if (entry.type === 'user') return entry.id === sub
    if (entry.type === 'group') return groups.includes(entry.id)
    return false
  })
}

export const checkHasResourcePermission = (context, allowedList = []) => {
  if (!hasResourcePermission(context, allowedList)) throw new AuthenticationError(unauthorized)
}