Files
Flash5/flash-extensions/flash-ext-security-core/docs
Zakaria El OrcheandClaude Opus 5 f28fc43150 feat(ext-security): add an OAuth 2.1 authorization server
flash-ext-security-oauth-server issues RFC 9068 access tokens (code + PKCE S256,
CIMD and DCR clients, RFC 8707 resources, rotating refresh tokens) for resources on
the application's own origin. Around it: SecurityExtension resolves a configured
origin instead of X-Forwarded-* headers, mechanisms expose schemes() and a route can
be restricted to some of them, McpConfig.mechanisms(...) uses that, OIDC bearers must
be typed at+jwt, and PublicUrl guards outbound fetches against internal addresses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 11:39:09 +00:00
..

flash-ext-security-core

Authentication and authorization for Flash, independent of any credential. Mechanisms — -oidc, -apikey, -form, or your own — register into one chain; this module owns everything downstream of "who is the caller".

app.install(new SecurityExtension()
        .users(principal -> users.findOrProvision(principal))   // optional: principal → your user
        .roles((identity, role, on) -> members.has(identity.user(User.class), role, on.get("project"))))
   .install(new OidcExtension(OidcProvider.of("sso", issuer, clientId, secret)));

The model

Type Role
AuthenticationMechanism reads one kind of credential: returns a Principal, null (not mine), or throws AuthenticationFailedException (mine, invalid)
Principal who the mechanism proved the caller to be — typed per mechanism (OidcPrincipal, ApiKeyPrincipal, …)
SecurityIdentity the current caller: principal(OidcPrincipal.class), user(User.class), hasRole, hasScope
UserResolver principal → application user, resolved lazily, once per request
RoleResolver whether a caller holds a role, optionally on a resource
AuthenticationEntryPoint the answer to a request that needs a caller and carries no credential

Mechanisms never write the response. That is what keeps the one mistake that matters impossible to make: a credential that was presented and rejected is always a 401, never a redirect into a sign-in page an API client cannot parse.

Annotations

On a handler or an MCP tool class:

@Authenticated any authenticated caller
@PermitAll anyone; a caller who authenticates is still identified, one who fails is anonymous
@RolesAllowed(value, on) any of the roles; on names the path/query parameters (tool arguments on MCP) identifying the resource
@ScopesAllowed(value) every one of the credential's scopes

@RolesAllowed(value = "MANAGER", on = "project") on /projects/{project}/keys asks the RoleResolver whether the caller is a manager of that project. A handler declaring roles with no RoleResolver configured fails the boot. Policies compile once; checking one allocates nothing.

The chain

Mechanisms are tried in registration order, then the session cookie. The first to return a principal wins. When none does:

  • a browser (Accept: text/html) is redirected to loginPage when one is set, otherwise to the only login method when it is a redirect, otherwise to /login;
  • anything else gets 401 with every mechanism's challenge in WWW-Authenticate.

entryPoint(...) replaces that, e.g. to pick an identity provider from the user's email domain.

enforce(policy, entryPoint, mechanisms) restricts a route to some mechanisms: any other credential, the session cookie included, is no credential there — a bearer-only endpoint a browser's cookie must not reach.

Origin

origin("https://app.example") is where the application is served. Session cookies, sign-in callbacks and token audiences are built from origin(req): the configured origin, or the request's own scheme and Host when none is. X-Forwarded-* is never read: the client can send anything, and an origin taken from it lets a caller choose which audience a token must have. Configure it in every deployment; the fallback is for development and tests.

Addresses someone else chose

PublicUrl.require(url) refuses anything that is not https on a public address. Call it before the server fetches a URL a customer or a client supplied — an identity provider, a metadata document — or that fetch becomes a request forgery against the server's own network.

Sessions

signIn(req, res, principal[, expiresAt]) stores the principal under a flash_session cookie; POST /auth/logout ends it and follows Principal.logoutUrl(). An expired session is handed to the SessionRefresher registered for its principal type, or ended. InMemorySessionStore is the default; sessions(...) swaps it for one that survives a restart or spans instances.

GET /auth/methods lists every registered LoginMethod for a client to render.

OpenAPI

With flash-ext-openapi present, every registered mechanism's SecurityScheme is published, and every protected operation lists them as alternatives, with its 401 and — for roles or scopes — its 403 and what it requires. Nothing to write per mechanism.

Writing a mechanism

security.mechanism(new AuthenticationMechanism() {
    public Principal authenticate(Request req) {
        String key = req.header("X-Key");
        if (key == null) return null;                                  // not mine
        Principal p = keys.get(key);
        if (p == null) throw new AuthenticationFailedException(null);  // mine, and invalid
        return p;
    }
    public List<SecurityScheme> schemes() { return List.of(SecurityScheme.bearer("key", "opaque")); }
});

Testing

flash-ext-security-test authenticates requests as any principal without an identity provider.