@odatano/cap-auth
v0.1.0
Published
Transport authentication for SAP CAP: registered lanes first, then CAP's own strategy
Maintainers
Readme
@odatano/cap-auth
Transport authentication for SAP CAP as a thin layer of lanes that always ends in CAP's own strategy. Authorization stays in the CDS model: the middleware opens or closes no path on its own and sends no terminal 401.
npm install @odatano/cap-auth{
"cds": {
"requires": {
"auth": {
"kind": "basic",
"impl": "@odatano/cap-auth",
"realm": "myapp",
"users": { "operator": { "password": "…", "roles": ["admin"] } }
}
}
}
}Request flow
- Basic lane (only when
kindisbasicormockedand the request carriesAuthorization: Basic): timing-safe compare againstusers, roles from the user entry. Wrong credentials are answered here with 401 andWWW-Authenticate: Basic realm="<realm>"; they never fall through to a token lane. Failures are throttled per client address and user id (20 per 15 min, then 429 withRetry-After, right password included). - Product lanes in registration order.
- Delegate: CAP's strategy for
kind(basic-auth,jwt-auth,ias-auth,dummy-auth), ordelegateImplwhen the host had its own custom impl. The delegate never seesimplordelegateImpl.
A request without credentials reaches the delegate; CAP's basic-auth
admits it as anonymous and the model decides (@requires: 'any' answers,
authenticated-user challenges).
Lanes
import { registerTransportLane, inLaneOf, markerUser, requestPath } from '@odatano/cap-auth';
registerTransportLane({
name: 'agent-token',
match: (req) => typeof req.headers['x-agent-token'] === 'string' && inLaneOf(requestPath(req), '/api/v1/service'),
authenticate: async (req, res) => {
// { handled: true } the lane sent a response (401/410/429), stop
// { pass: true } req.user is set, continue into CAP
// { user } same, sets req.user on the way
// { next: true } not this lane's request; next lane, then the delegate
return { user: markerUser('agent-token-transport') };
}
});Register at plugin load, before CAP builds its middlewares; a later call
throws. The registry is process-global, so a lane registered through one
copy of the package is seen by the copy CAP loads as impl.
markerUser(id) is a cds.User that owns nothing and has no roles. It
still passes the model's authenticated-user (every cds.User with an id
does): a handler reached under a marker is authenticated only once the
product's own hook says so.
Options (cds.requires.auth)
| Key | Default | Purpose |
|---|---|---|
| kind | required | Delegate strategy: basic, mocked, jwt, xsuaa, ias, dummy |
| impl | | @odatano/cap-auth |
| delegateImpl | | A host's own auth impl (resolved like CAP resolves impl); replaces kind's strategy as the delegate, and switches the basic lane off (that gate reads the credentials) |
| users | | Users of the basic lane, CAP mocked-users shape ("pw", true, { password, roles, attr, tenant, id }, "*") |
| realm | odatano | WWW-Authenticate realm |
| basicThrottle | { windowMs: 900000, maxFailures: 20, maxKeys: 10000, maxKeysPerAddress: 64 } | Failure throttle |
Contract
@odatano/cap-auth/contract is the request/outcome table as a test matrix
over HTTP, on the runner's describe/it/expect globals (or a runner
option). A product runs it against its own booted app:
import cds from '@sap/cds';
import { runTransportAuthContract } from '@odatano/cap-auth/contract';
cds.env.requires.auth = { kind: 'basic', impl: '@odatano/cap-auth', users: { op: { password: 'pw', roles: ['admin'] } } };
const cap = cds.test(__dirname + '/../..');
runTransportAuthContract({
url: () => cap.url,
anonymousPath: '/api/v1/indexer/getLiveness()',
authenticatedPath: '/api/v1/indexer/getRuntimeInfo()',
operator: { user: 'op', password: 'pw' },
lane: { header: 'x-agent-token', value: 'ngat_x', insidePath: '/api/v1/nightgate/$batch', outsidePath: '/api/v1/admin/x', insideStatus: 401 }
});| Request | Outcome |
|---|---|
| no credentials, element any | 200 |
| no credentials, authenticated-user element | 401, WWW-Authenticate from the delegate |
| valid basic | the operator with its roles |
| wrong basic, under budget | 401 with the realm |
| wrong basic, over budget | 429 + Retry-After, right or wrong password |
| lane header on a lane path | the lane decides |
| lane header off any lane path | 401 from the delegate |
| basic and lane header | basic wins |
The throttle row runs last (it locks the test client's address for the configured window).
License
Apache-2.0
