Skip to content
Open
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
14 changes: 12 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ contract (column-name keys, `{col}_display`/`{col}_ref` expansion, `__SECRET_SET
| DivKit UI | `GET /api/divkit/{shell,home,menu,account,settings}` and `/api/divkit/{catalogs,documents}/{name}[/{id}|/new|/{id}/edit]`, `/api/divkit/registers/{name}` (ui-starter) |
| Theme/config | `GET /api/theme`, `GET /api/config`, `GET /api/branding` (ui-starter) |
| Events | `GET /api/events` — SSE stream of CRUD/posting changes (ui-starter) |
| Auth | `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me` (auth-starter) |
| Auth | `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`; opt-in magic-link: `POST /api/auth/magic/request`, `GET /api/auth/magic/verify` (auth-starter) |
| Import | `POST /api/import/{catalogs,documents}/{name}/csv[/preview]` (import-starter) |
| Desktop | `GET /api/desktop/ready`, `GET /api/desktop/manifest` (desktop-starter) |
| MCP | `POST /mcp` — streamable-HTTP MCP transport (mcp-starter) |
Expand Down Expand Up @@ -225,9 +225,19 @@ and `config(key,value)` reference.
the token via `onec.auth.oidc.*`; RP-initiated logout.
- **`resource-server`** — stateless JWT bearer validation, no session/CSRF.

**Passwordless magic-link** (opt-in, in-memory mode): set `onec.auth.magic-link.enabled=true` and a
user's `email` on `onec.auth.users[*]`. `POST /api/auth/magic/request {email}` emails a single-use
link (via the `onec-mail-starter`); `GET /api/auth/magic/verify?token=…` consumes it, establishes the
session like `/api/auth/login`, and redirects into the app. Tokens are stored hashed in
`onec_auth_magic_link` (needs a `DataSource`) so links validate across nodes; the request endpoint
returns `202` regardless of whether the address is registered (no account enumeration). Delivery and
token storage are pluggable via `MagicLinkSender` / `MagicLinkTokenStore` beans; the login screen
advertises it through `AuthMethods.magicLinkEnabled`.

`/api/**` requires authentication (except the public allowlist: `/error`, `/api/theme`,
`/api/config`, `/api/branding`, `/api/auth/login`, `/api/auth/me`, `/api/divkit/login`,
`/api/desktop/**`). **Per-entity RBAC is deny-by-default**: a catalog/document/register is invisible
`/api/auth/magic/request`, `/api/auth/magic/verify`, `/api/desktop/**`). **Per-entity RBAC is
deny-by-default**: a catalog/document/register is invisible
and uneditable unless its `@AccessControl` read/write roles grant the caller; the `ADMIN` role is a
superuser. Override the whole thing by setting `onec.auth.enabled=false` and supplying your own
`SecurityFilterChain`.
Expand Down
5 changes: 5 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ except Kafka inbound). Standard Spring keys (`spring.datasource.*`, `spring.mail
| --- | --- | --- | --- |
| `onec.auth.csrf-ignored-paths` | `List<String>` | — | Request paths exempted from CSRF protection in the cookie-based modes (IN_MEMORY and OIDC). Defaults to just the login endpoint. Add a path here to expose an anonymous, CSRF-free `POST` (e.g. a public lead/intake form) without having to override the whole `SecurityFilterChain` (issue #30). Ant patterns are supported (e.g. `/api/public/**`). Ignored in RESOURCE_SERVER, where CSRF is already disabled. |
| `onec.auth.enabled` | `Boolean` | `true` | Master switch for the auth starter. When false, no SecurityFilterChain is contributed and the application can wire its own. |
| `onec.auth.magic-link.base-url` | `String` | — | Absolute base URL used to build the link placed in the email, e.g. `https://app.example.com`. Leave blank to derive it from the incoming request's origin (scheme/host/port) — correct for single-origin deployments, but set it explicitly when the public URL differs from what the app sees behind a proxy/load balancer. |
| `onec.auth.magic-link.enabled` | `Boolean` | `false` | Whether magic-link login is wired and advertised on the login screen. Off by default. |
| `onec.auth.magic-link.redirect-path` | `String` | `/` | Where the browser lands after a link is successfully verified and the session established. A same-origin path (must start with `/`); defaults to the app root. |
| `onec.auth.magic-link.subject` | `String` | `Your sign-in link` | Subject line of the magic-link email. |
| `onec.auth.magic-link.token-validity` | `Duration` | `15m` | How long an emailed link stays valid before it must be re-requested. Single-use regardless: following a link consumes it. Kept short — a link is a bearer credential sitting in an inbox. |
| `onec.auth.mode` | `Mode` | `in-memory` | Which authentication backend the starter wires. Selecting a mode only changes how identities are authenticated — the `/api/**`-requires-auth model is the same across all of them. <ul> <li>IN_MEMORY (default) — username/password against `onec.auth.users`, session cookie, JSON `/api/auth/login`. Zero external dependencies.</li> <li>OIDC — server-side OpenID Connect authorization-code login against any standard provider (Keycloak, Zitadel, …). Keeps the session-cookie model; "login" becomes a redirect to `/oauth2/authorization/{registrationId}`. Configure the provider with the standard `spring.security.oauth2.client.*` properties.</li> <li>RESOURCE_SERVER — stateless bearer-token validation. The client obtains tokens from the IdP directly and sends `Authorization: Bearer ...`. Configure with `spring.security.oauth2.resourceserver.jwt.issuer-uri`.</li> </ul> |
| `onec.auth.oidc.logout-path` | `String` | `/logout` | Path the SPA navigates to for RP-initiated logout in OIDC mode. A GET here clears the local session and redirects to the IdP's end-session endpoint. Surfaced to the SPA via `/api/auth/me` as `logoutUrl`. |
| `onec.auth.oidc.post-logout-redirect-uri` | `String` | `{baseUrl}` | Where the IdP sends the browser after ending its session. `{baseUrl}` expands to the app's own origin (scheme://host:port), so the user lands back on the SPA shell. This value must be registered under the IdP client's valid post-logout redirect URIs. |
Expand Down
74 changes: 73 additions & 1 deletion onec-auth-starter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ to contribute nothing and wire your own security.
| `onec.auth.users[*].username` | — | In-memory account username. |
| `onec.auth.users[*].password` | — | Plaintext password; BCrypt-encoded at startup. Missing username **or** password fails startup. |
| `onec.auth.users[*].roles` | `[]` | Role names (stored as `ROLE_*` authorities by Spring's `User.roles(...)`). |
| `onec.auth.users[*].email` | — | Optional email that identifies this user for magic-link login (opt-in per account). See [magic-link](#passwordless-magic-link-login). |
| `onec.auth.magic-link.enabled` | `false` | Opt-in passwordless email login (in-memory mode). See [magic-link](#passwordless-magic-link-login). |
| `onec.auth.magic-link.token-validity` | `15m` | How long an emailed link stays valid; single-use regardless. |
| `onec.auth.magic-link.base-url` | — | Absolute base URL for the emailed link (e.g. `https://app.example.com`). Blank derives it from the request origin. |
| `onec.auth.magic-link.redirect-path` | `/` | Same-origin path the browser lands on after a link is verified. |
| `onec.auth.magic-link.subject` | `Your sign-in link` | Subject line of the magic-link email. |
| `onec.auth.session.timeout` | `8h` | Idle session timeout for the cookie modes (`in-memory`, `oidc`), applied to the servlet container and **overriding** Spring Boot's 30-minute default. Slides on every request. Ignored in `resource-server` (stateless). |
| `onec.auth.session.remember-me.enabled` | `true` | In-memory only: issue a persistent remember-me cookie on login and re-authenticate from it after the session lapses. |
| `onec.auth.session.remember-me.validity` | `14d` | How long the remember-me cookie stays valid. |
Expand All @@ -67,7 +73,12 @@ Default `public-paths`:
/error
/api/theme
/api/config
/api/branding
/api/auth/login
/api/auth/me
/api/divkit/login
/api/auth/magic/request
/api/auth/magic/verify
/api/desktop/ready
/api/desktop/manifest
```
Expand Down Expand Up @@ -131,6 +142,8 @@ CSRF protection is **enabled** using `CookieCsrfTokenRepository.withHttpOnlyFals
| `POST` | `/api/auth/login` | public, CSRF-exempt | `{"username":"...","password":"...","remember":true}` | `200` `{"authenticated":true,"username":"...","roles":["ROLE_ADMIN",...]}`; `401` on bad credentials; `400` if username/password missing. `remember` is optional (default `true`) and, when remember-me is enabled, controls whether the persistent cookie is issued |
| `GET` | `/api/auth/me` | public | — | Current user, or `{"authenticated":false,"username":"","roles":[]}` when anonymous |
| `POST` | `/api/auth/logout` | authenticated (needs CSRF header) | — | `204 No Content`; cancels the remember-me cookie, clears the security context, and invalidates the session |
| `POST` | `/api/auth/magic/request` | public, CSRF-exempt | `{"email":"..."}` | `202 Accepted` always (no account enumeration). Opt-in — only mapped when `onec.auth.magic-link.enabled=true`. See [magic-link](#passwordless-magic-link-login) |
| `GET` | `/api/auth/magic/verify` | public | `?token=…` | `302` into the app on success (session established) or to `/login?error=link` if invalid/expired/used. Opt-in as above |

`roles` are the granted authorities verbatim, so they include the `ROLE_` prefix (a `USER` role
appears as `ROLE_USER`).
Expand Down Expand Up @@ -180,6 +193,64 @@ curl -s -b "$JAR" \
curl -s -b "$JAR" -X POST -H "X-XSRF-TOKEN: $XSRF" "$BASE/api/auth/logout"
```

## Passwordless magic-link login

An **opt-in** alternative to the password form for the `in-memory` mode: the user enters their email,
the framework emails a single-use link, and following it establishes the session — no password typed.
It composes with password login (both appear on the login screen); it is **not** used in the `oidc` /
`resource-server` modes, where the IdP owns sign-in.

The mental model mirrors SSO: just as an OIDC backend keys a user by an external identity, here an
in-memory user *declares its email* as the identity the link looks up. So enabling it is two steps —
flip the switch, and give the accounts that may use it an `email`:

```yaml
onec:
auth:
magic-link:
enabled: true
# token-validity: 15m # how long a link is valid (single-use regardless)
# base-url: https://app.example.com # else derived from the request origin
# redirect-path: / # where verify lands the browser
users:
- username: admin
password: "s3cret"
roles: [ADMIN]
email: admin@example.com # opt-in: a user with no email can't use magic-link
```

**Requirements (fail-fast at startup if missing):**

- The **`onec-mail-starter`** on the classpath with a configured provider — the default
`MagicLinkSender` emails the link via `MailService`.
- A **`DataSource`** — tokens are persisted (hashed) in `onec_auth_magic_link` so a link issued by one
node validates on any node of a horizontally-scaled deployment.

**Flow:**

1. `POST /api/auth/magic/request {"email":"..."}` → if the address maps to a user, a single-use token
is generated, its SHA-256 hash stored with an expiry, and the link emailed. The endpoint returns
`202` **whether or not** an account matched, so it can't be used to enumerate registered addresses.
2. `GET /api/auth/magic/verify?token=…` → validates and consumes the token atomically (single-use,
expiry-checked), establishes the `SecurityContext` in the session exactly like `/api/auth/login`,
and redirects to `redirect-path`. An invalid/expired/used link redirects to `/login?error=link`.

**Security properties:** 256-bit `SecureRandom` tokens; only their hash is persisted (a store leak
can't be replayed); single-use consumption closes the replay window; short default validity (15 min);
and uniform `202` responses (no enumeration).

**Extension points** (register your own bean to override the default):

| SPI | Default | Replace to… |
|-----|---------|-------------|
| `MagicLinkUserLookup` | reads `onec.auth.users` (match `email`, else email-shaped `username`) | resolve emails against your own user directory |
| `MagicLinkTokenStore` | JDBC (`onec_auth_magic_link`) | use a different store (e.g. Redis) |
| `MagicLinkSender` | emails via `MailService` | deliver another way / fully control the message body |

The login screen advertises the option through `AuthMethods.magicLinkEnabled` (the same SPI that
carries the password flag and SSO providers); the onec UI renders it as the `onec-magic-link` block on
the DivKit login card.

## OIDC (Keycloak, Zitadel, …)

The `oidc` and `resource-server` modes are plain Spring Security OAuth2 — **any** standard OIDC
Expand Down Expand Up @@ -342,7 +413,8 @@ onec:
The available methods are exposed to the UI through the `com.onec.auth.spi.AuthMethodsProvider` bean
(contract in `onec-framework`), so the UI module can build the login screen server-side without
depending on this module. In the onec UI this drives the DivKit login card (`GET /api/divkit/login`):
the server emits a password form and/or one button per SSO provider, and adding an IdP needs no client
the server emits a password form, the [magic-link](#passwordless-magic-link-login) block (when
`AuthMethods.magicLinkEnabled`), and/or one button per SSO provider, and adding an IdP needs no client
change.

`AuthMethodsProvider` is the single source of the password flag, mode, and logout URL. To **add** a
Expand Down
9 changes: 9 additions & 0 deletions onec-auth-starter/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ dependencies {
api(libs.spring.boot.starter.security)
implementation(libs.spring.boot.starter.web)

// Optional: the default magic-link sender emails the link via MailService. compileOnly so apps
// without the mail starter still use the auth starter; the mail-backed sender bean is wired only
// when MailService is on the classpath (and a custom MagicLinkSender can replace it entirely).
compileOnly(project(":onec-mail-starter"))

// OIDC support (Keycloak, Zitadel, …). Only exercised when onec.auth.mode = oidc /
// resource-server; the standard Spring Boot auto-config for these stays dormant until
// issuer/client properties are set, so in-memory deployments pay nothing but the jars.
Expand All @@ -22,5 +27,9 @@ dependencies {

testImplementation(libs.spring.boot.starter.test)
testImplementation(libs.spring.boot.test)
// The JDBC token store is exercised against H2 (the same engine single-node deployments use).
testImplementation(libs.h2)
// Verify the default mail-backed MagicLinkSender against the real MailService/MailMessage types.
testImplementation(project(":onec-mail-starter"))
testRuntimeOnly(libs.junit.platform.launcher)
}
Loading