Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
268 changes: 164 additions & 104 deletions tests/abac/index.test.js
Original file line number Diff line number Diff line change
@@ -1,117 +1,177 @@
/*
* Cross-component E2E: auth.policy-verifier ABAC verification
* Cross-component E2E: auth.policy-verifier ABAC verification.
*
* Prerequisites: docker compose up (policy-verifier + provider + redis)
* Verifier: http://localhost:3097
*
* The allow/deny decisions below run against tokens auth.provider actually
* minted through `login -> /authorize (PKCE) -> /token`. That is the point of
* o3co/auth#3: a self-signed token proves the verifier can validate a
* signature, not that the two components agree on what a token looks like.
*
* Hand-signed tokens remain only where the provider cannot be made to produce
* the input — a wrong issuer, a wrong audience, an expired token, a scopeless
* token. Those are envelope negatives, and each says so.
*/
import { describe, it, expect } from 'vitest';
import axios from 'axios';
import { describe, it, expect, beforeAll } from 'vitest';
import jwt from 'jsonwebtoken';

const VERIFIER_URL = process.env.VERIFIER_URL || 'http://localhost:3097';
const JWT_SECRET = process.env.OAUTH_JWT_SECRET || 'test-secret-for-e2e';
const ISSUER = process.env.OAUTH_JWT_ISSUER || 'https://auth.e2e.test';
const AUDIENCE = process.env.OAUTH_JWT_AUDIENCE || 'https://api.e2e.test';

/*
* The verifier validates `iss` / `aud` / the `typ` header alongside the
* signature (RFC 9068 §4 — auth.policy-verifier#105), so a token that carries
* only a scope is rejected as `invalid_token` before any rule runs. Mint every
* E2E token through here so the envelope matches what the deployment pins.
import {
AUDIENCE,
ISSUER,
JWT_SECRET,
codeFlow,
decodeJwt,
login,
verify,
} from '../shared/oauthFlow.js';

/**
* Envelope-correct hand-signed token, for the negatives the real flow cannot
* produce. Matches what the deployment issues (RFC 9068 §4: `iss`, `aud`, and
* the `at+jwt` header) so a rejection is attributable to the thing under test
* rather than to a malformed envelope.
*/
function signToken(claims, options = {}) {
return jwt.sign(
{ iss: ISSUER, aud: AUDIENCE, ...claims },
JWT_SECRET,
{ expiresIn: 60, header: { typ: 'at+jwt' }, ...options },
);
return jwt.sign({ iss: ISSUER, aud: AUDIENCE, ...claims }, JWT_SECRET, {
expiresIn: 60,
header: { typ: 'at+jwt' },
...options,
});
}

const verifier = axios.create({
baseURL: VERIFIER_URL,
validateStatus: () => true,
/** Grant carrying `read:project`. */
let projectGrant;
/** Grant carrying only `read:project_member` — used for the deny case. */
let memberGrant;

beforeAll(async () => {
const session = await login();
expect(session.status).toBe(200);
projectGrant = await codeFlow({ cookie: session.cookie, scope: 'openid read:project' });
memberGrant = await codeFlow({ cookie: session.cookie, scope: 'openid read:project_member' });
}, 30_000);

describe('ABAC: POST /verify with provider-issued access tokens', () => {
it('allows when the minted scope matches the resource action', async () => {
const res = await verify({ token: projectGrant.access_token });
expect(res.status).toBe(200);
expect(res.body.decision).toBe('allow');
// The subject travels intact from the provider's `sub` claim to the
// verifier's decision — the cross-component identity contract.
expect(res.body.subject).toBe('user-e2e-1');
});

it('denies when the minted scope does not match the resource action', async () => {
// A real token, correctly signed, simply not carrying `read:project`.
const res = await verify({ token: memberGrant.access_token });
expect(res.status).toBe(403);
expect(res.body.decision).toBe('deny');
expect(res.body.code).toBe('invalid_scope');
});

it('allows a nested resource when the minted scope matches', async () => {
const res = await verify({
token: memberGrant.access_token,
resource: 'project:1.member:2',
});
expect(res.status).toBe(200);
expect(res.body.decision).toBe('allow');
});
});

describe('ABAC: only access tokens are decision inputs', () => {
/*
* The provider mints three JWTs from one grant, all signed with the same
* key. Only the access token is a bearer credential for a resource server:
* an id_token is an assertion about an authentication event delivered to
* the client, and a refresh token is a credential for the token endpoint.
* Presenting either at /verify must fail.
*
* NOTE: the `typ` header is the ONLY thing that distinguishes them. There
* is no claim-level check — the verifier pins `at+jwt` and rejects
* everything else before any rule runs. So if auth.provider ever changes
* the `typ` it stamps, or the verifier ever relaxes the pin, these two
* tests are what catch it. Do not weaken them into "some 4xx".
*/

it('rejects the id_token from the same grant', async () => {
const res = await verify({ token: projectGrant.id_token });
expect(decodeJwt(projectGrant.id_token).header.typ).toBe('id+jwt');
expect(res.status).toBe(401);
expect(res.body.decision).toBe('deny');
expect(res.body.code).toBe('invalid_token');
});

it('rejects the refresh token from the same grant', async () => {
const res = await verify({ token: projectGrant.refresh_token });
expect(decodeJwt(projectGrant.refresh_token).header.typ).toBe('rt+jwt');
expect(res.status).toBe(401);
expect(res.body.decision).toBe('deny');
expect(res.body.code).toBe('invalid_token');
});
});

describe('ABAC: POST /verify', () => {
it('allows when scope matches resource action', async () => {
const token = signToken({ user: { id: 1 }, scope: 'read:project' });

const res = await verifier.post('/verify', {
resource: 'project:1',
action: 'read',
}, {
headers: { Authorization: `Bearer ${token}` },
});

expect(res.status).toBe(200);
expect(res.data.decision).toBe('allow');
});

it('denies when scope does not match resource action', async () => {
const token = signToken({ user: { id: 1 }, scope: 'write:project' });

const res = await verifier.post('/verify', {
resource: 'project:1',
action: 'read',
}, {
headers: { Authorization: `Bearer ${token}` },
});

expect(res.status).toBe(403);
expect(res.data.decision).toBe('deny');
expect(res.data.code).toBe('invalid_scope');
});

it('denies with 401 when Authorization header is missing', async () => {
const res = await verifier.post('/verify', {
resource: 'project:1',
action: 'read',
});

expect(res.status).toBe(401);
expect(res.data.decision).toBe('deny');
expect(res.data.code).toBe('missing_token');
});

it('denies with 401 for invalid JWT', async () => {
const res = await verifier.post('/verify', {
resource: 'project:1',
action: 'read',
}, {
headers: { Authorization: 'Bearer invalid.token.here' },
});

expect(res.status).toBe(401);
expect(res.data.decision).toBe('deny');
expect(res.data.code).toBe('invalid_token');
});

it('denies with 401 for expired JWT', async () => {
const token = signToken({ user: { id: 1 }, scope: 'read:project' }, { expiresIn: -1 });

const res = await verifier.post('/verify', {
resource: 'project:1',
action: 'read',
}, {
headers: { Authorization: `Bearer ${token}` },
});

expect(res.status).toBe(401);
expect(res.data.decision).toBe('deny');
expect(res.data.code).toBe('invalid_token');
});

it('allows nested resource when scope matches', async () => {
const token = signToken({ user: { id: 1 }, scope: 'read:project_member' });

const res = await verifier.post('/verify', {
resource: 'project:1.member:2',
action: 'read',
}, {
headers: { Authorization: `Bearer ${token}` },
});

expect(res.status).toBe(200);
expect(res.data.decision).toBe('allow');
});
describe('ABAC: RFC 9068 envelope validation', () => {
it('denies with 401 when Authorization header is missing', async () => {
const res = await verify({ token: undefined });
expect(res.status).toBe(401);
expect(res.body.decision).toBe('deny');
expect(res.body.code).toBe('missing_token');
});

it('denies with 401 for invalid JWT', async () => {
const res = await verify({ token: 'invalid.token.here' });
expect(res.status).toBe(401);
expect(res.body.decision).toBe('deny');
expect(res.body.code).toBe('invalid_token');
});

it('denies with 401 for expired JWT', async () => {
const res = await verify({
token: signToken({ sub: 'user-e2e-1', scope: 'read:project' }, { expiresIn: -60 }),
});
expect(res.status).toBe(401);
expect(res.body.decision).toBe('deny');
expect(res.body.code).toBe('invalid_token');
});

it('denies a token from a different issuer', async () => {
// RFC 9068 §4 — a correctly signed token is still not this
// deployment's token. Shared-secret HS256 makes this the only thing
// standing between two providers on the same key.
const res = await verify({
token: signToken({ sub: 'user-e2e-1', scope: 'read:project', iss: 'https://evil.e2e.test' }),
});
expect(res.status).toBe(401);
expect(res.body.decision).toBe('deny');
expect(res.body.code).toBe('invalid_token');
});

it('denies a token minted for a different audience', async () => {
const res = await verify({
token: signToken({ sub: 'user-e2e-1', scope: 'read:project', aud: 'https://other.e2e.test' }),
});
expect(res.status).toBe(401);
expect(res.body.decision).toBe('deny');
expect(res.body.code).toBe('invalid_token');
});

it('denies a token whose `typ` is not at+jwt', async () => {
const res = await verify({
token: signToken({ sub: 'user-e2e-1', scope: 'read:project' }, { header: { typ: 'JWT' } }),
});
expect(res.status).toBe(401);
expect(res.body.decision).toBe('deny');
expect(res.body.code).toBe('invalid_token');
});

it('denies a scopeless token against a scope-only pipeline', async () => {
// The provider will not mint a scopeless token for this client, so the
// input is hand-signed. auth.policy-verifier#104: an empty rule set
// denies rather than allowing by vacuous truth.
const res = await verify({ token: signToken({ sub: 'user-e2e-1' }) });
expect(res.status).toBe(403);
expect(res.body.decision).toBe('deny');
expect(res.body.code).toBe('invalid_scope');
});
});
73 changes: 68 additions & 5 deletions tests/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,82 @@ services:
- npmrc
ports:
- "3099:3000"
volumes:
# The template ships an EMPTY clients.yaml and no users.yaml at all, so
# the E2E supplies both. Paths are cwd-relative in application.conf and
# the image's WORKDIR is /home/node/templates/standalone.
- ./provider/clients.yaml:/home/node/templates/standalone/config/clients.yaml:ro
- ./provider/users.yaml:/home/node/templates/standalone/config/users.yaml:ro
environment:
- OAUTH_JWT_SECRET=test-secret-for-e2e
# auth.provider requires a canonical issuer and never derives one from the
# request (auth.provider#266). It is an identifier, not a URL anything
# fetches, so the E2E pins a fixed one both services agree on.
- OAUTH_JWT_ISSUER=https://auth.e2e.test
- SESSION_SECRET=test-session-secret
- SESSION_STORAGE_REDIS_URL=redis://redis:6379
- CLIENT_USER_AUTHENTICATE_URL=http://127.0.0.1:3000/authenticate
- CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL=http://127.0.0.1:3000/authenticate/token
- SESSION_SECURE=false
# __Host- prefixed default requires secure=true; plain-HTTP E2E needs both overrides
- SESSION_NAME=auth.session
- CLIENT_CODE_ENDPOINT_URI=redis://redis:6379

# ---- Redis-backed state -------------------------------------------
# DEPLOYMENT_MODE=multi makes the provider itself audit this block:
# boot FAILS naming every offender if any shared store is still held in
# process memory (auth.provider#271/#315). The E2E declares it so a
# future drift back to an in-memory store is a red build, not a suite
# that silently stops exercising the Redis paths — the exact failure
# this issue was filed for.
- DEPLOYMENT_MODE=multi
# THE key the old compose was missing. One ioredis socket per replica
# backs the refresh-token-family store AND — via
# standaloneRedisClientsModule — the 4 user-session stores, the rate
# limiter, and the authorization-code repository. It defaults to
# redis://localhost:6379, which is nothing inside the container, so
# without this the provider retried a dead socket while the old suite
# stayed green.
- REFRESH_TOKEN_FAMILY_STORE_REDIS_URL=redis://redis:6379
# express-session's own store; a separate connection from the shared
# ioredis socket above, hence a separate URL.
- SESSION_STORAGE_REDIS_URL=redis://redis:6379
# The four user-session stores (session, RP registry, family index,
# federation index) — memory by default, which DEPLOYMENT_MODE=multi
# refuses.
- USER_SESSION_STORES_ADAPTER=redis
# OAuth-endpoint rate-limit counters — memory by default, likewise
# refused under multi.
- RATE_LIMITER_ADAPTER=redis
# Authorization-code repository. Already the template default, pinned
# here because the whole point of the suite is that codes round-trip
# through Redis rather than a per-process Map.
- OAUTH_CODE_ADAPTER=redis
# NOTE: the legacy CLIENT_CODE_ENDPOINT_URI that used to sit here is
# gone. It feeds `repositories.code.redis.endpointUri`, which nothing
# reads once `oauth.code.adapter = "redis"` routes the code repository
# onto the shared socket above — it was a no-op pretending to be
# configuration.

# ---- Repositories --------------------------------------------------
# The template defaults the user repository to "http", which needs an
# external identity service. The E2E has none, so it uses the built-in
# YAML directory and mounts users.yaml above.
- CLIENT_USER_TYPE=yaml

# ---- Issuance policy the suite means to exercise --------------------
# RFC 8707 resource indicators are opt-in (secure-by-default). The E2E
# turns them on because that is how `/authorize?resource=...` stamps
# `aud: https://api.e2e.test` on the access token — the audience
# auth.policy-verifier pins. Without it the provider mints a token the
# verifier is right to reject, and the cross-component contract cannot
# be exercised at all.
- OAUTH_RESOURCE_INDICATOR_ENABLED=true
# #297/#320: refuse to mint for a user whose email the Store has not
# verified. On by default here (it ships off) so the happy path proves
# the gate lets a verified user through, and the negative case proves it
# stops an unverified one.
- OAUTH_REQUIRE_EMAIL_VERIFIED=true
# Public clients are forced onto S256 by the provider regardless; pinning
# it globally means a future confidential client in this suite cannot
# quietly fall back to `plain`.
- OAUTH_GRANTS_AUTHORIZATION_CODE_PKCE_REQUIRE_S256=true
depends_on:
redis:
condition: service_healthy
Expand Down Expand Up @@ -79,7 +141,8 @@ services:
- OAUTH_JWT_SECRET=test-secret-for-e2e
# auth.policy-verifier validates iss / aud / typ per RFC 9068 §4 and
# refuses to boot without them (auth.policy-verifier#105). The issuer must
# match what the provider stamps.
# match what the provider stamps, and the audience must match what the
# provider's `resource` parameter puts on the access token.
- OAUTH_JWT_ISSUER=https://auth.e2e.test
- OAUTH_JWT_AUDIENCE=https://api.e2e.test
depends_on:
Expand Down
Loading
Loading