/**
* @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)
}
Source