SSO
Per-tenant OpenID Connect configuration. The package handles discovery, JWKS-backed id_token verification, nonce/state binding, and SSRF guards on the issuer URL.
Configuration
bash
node ace configure @adonisjs-lasagna/saas-tenancy --with=sso
npm install josejose is an optional peer dependency; only required when SSO is enabled.
What gets verified
Every callback hits SsoService.handleCallback(), which:
- Generates state with
randomBytes(16), single-use, 600 s TTL. - Generates nonce with
randomBytes(16), bound to state, included on the auth URL. - Verifies the token endpoint returns an
id_token. - Verifies the
id_tokenagainst the IdP's JWKS (cached 1 h via discovery). - Checks
iss,aud,expviajose.jwtVerify(60 s clock tolerance). - Confirms the
noncein theid_tokenpayload matches the value bound to state.
Any mismatch throws and aborts the callback before claims surface.
Discovery hardening
The discover() method:
- Verifies the discovery doc's
issuermatches the requested issuer (OIDC Discovery 1.0 §4.3). - Applies
validateExternalHttpsUrlto the discoveredtoken_endpointandjwks_uri; defends against SSRF (loopback, RFC 1918, link-local, cloud metadata, IPv6 brackets).
Storing config
ts
import { SsoService } from '@adonisjs-lasagna/saas-tenancy/services'
const sso = await app.container.make(SsoService)
await sso.upsert(tenant.id, {
issuerUrl: 'https://login.acme.com/.well-known/openid-configuration',
clientId: env.get('ACME_OIDC_CLIENT_ID'),
clientSecret: env.get('ACME_OIDC_CLIENT_SECRET'),
redirectUri: 'https://app.example.com/auth/callback',
scopes: ['openid', 'profile', 'email'],
})The admin REST endpoint that wires this also runs issuerUrl through validateExternalHttpsUrl() so a mis-configured tenant cannot make the server reach a private network.
Login flow
ts
import { SsoService } from '@adonisjs-lasagna/saas-tenancy/services'
router
.get('/auth/login', async ({ request, response }) => {
const sso = await app.container.make(SsoService)
const tenant = await request.tenant()
const { authUrl, state } = await sso.startLogin(tenant.id)
response.cookie('oidc_state', state, { httpOnly: true, secure: true })
return response.redirect(authUrl)
})
.as('auth.login')
router
.get('/auth/callback', async ({ request }) => {
const sso = await app.container.make(SsoService)
const tenant = await request.tenant()
const claims = await sso.handleCallback(tenant.id, {
code: request.input('code'),
state: request.input('state'),
cookieState: request.cookie('oidc_state'),
})
// claims.sub, claims.email, claims.name, …
})
.as('auth.callback')