114 lines
17 KiB
Markdown
114 lines
17 KiB
Markdown
# Synology SSO and local recovery
|
||
|
||
## First setup on this workstation
|
||
|
||
1. Start the local stack and apply migrations. The existing ignored `.env` already contains random `TOKEN_ENCRYPTION_KEY` and `LOCAL_ADMIN_SETUP_TOKEN` values; preserve them.
|
||
2. Open `http://localhost:5173/local-admin`. This URL is intentionally absent from normal navigation.
|
||
3. Read `LOCAL_ADMIN_SETUP_TOKEN` locally from `.env` and enter it in the setup form. Choose your own username, display name and password (12–128 characters). No account or default password is shipped. Only one password-based account can be created; setup closes atomically after it exists. The setup token can then be removed from `.env` and the API restarted.
|
||
4. Save the recovery credentials in your password manager. This account can configure SSO and open the app even when the identity provider is unavailable, provided the Model Delivery API is reachable.
|
||
5. Complete the SSO form using your actual Synology values. Save, then use **בדיקת ההגדרות השמורות** to test discovery. This checks metadata connectivity and issuer matching, not the full interactive sign-in. Log out of the app and use its normal SSO button to verify the real login.
|
||
|
||
For a fresh installation elsewhere, generate a 32-byte random base64 encryption key and a separate long random setup token as described in `.env.example`. Preserve the encryption key with backups; losing it prevents decrypting saved SSO settings and provider tokens. No secret is returned by the settings API after saving.
|
||
|
||
## Synology settings
|
||
|
||
In Synology SSO Server, enable OIDC and register an OIDC application for Model Delivery. Record its Application ID, Application Secret and exact Redirect URI. Synology's [application documentation](https://kb.synology.com/en-global/DSM/help/SSOServer/sso_server_application_list?version=7) describes these fields. Obtain the Well-known URL from your installed SSO Server's OIDC service settings; copy the `issuer` value from that discovery document without altering its path or trailing slash.
|
||
|
||
| Model Delivery setting | Value to use |
|
||
| --- | --- |
|
||
| Issuer | Exact `issuer` from your server's discovery document |
|
||
| Well-known URL | Synology's full discovery URL; optional if discovery from the issuer works |
|
||
| Application ID / Secret | Values from the OIDC application registered in Synology |
|
||
| Redirect URI | `http://localhost:5173/api/auth/callback` for current local development; `http://localhost:4173/api/auth/callback` for built preview; your HTTPS application URL plus `/api/auth/callback` after deployment |
|
||
| Client authentication | Match the provider's advertised method: `client_secret_post` or `client_secret_basic`; `none` is available for explicitly registered public clients |
|
||
| Scopes | Always `openid`; Model Delivery adds `groups` to each authorization request. Add only other scopes supported by the installed server |
|
||
| Display-name claim | Default `name`; configure `username` if preferred. Fallbacks include given name, preferred username, Synology's username, email and subject |
|
||
| Group claim | Default `groups`, matching Synology's documented OIDC group claim |
|
||
| Allowed groups | One exact Synology/AD group name per line; a user may belong to any one of them |
|
||
| Restrict by group | Leave off until the allowlist is ready and a test SSO login has shown the expected group in **החשבון שלי** |
|
||
| offline_access | Optional, off by default. Enable only if your Synology version/application supports refresh tokens and this scope |
|
||
| prompt=consent | Optional explicit consent request if required by the provider |
|
||
|
||
The server validates issuer, metadata endpoints and signatures using `openid-client`, with Authorization Code + PKCE S256, random state and nonce. Do not weaken verification to accommodate mismatched Synology URLs. Correct the server's externally advertised URLs and trusted certificate. This workstation's Synology sign-in and UserInfo lookup are now working; installed-version refresh-token support remains to be confirmed.
|
||
|
||
`APP_ORIGINS` is an allowlist in the server's `.env`, independent of the settings form. Callback origins must be in it. On deployment set the real HTTPS app origin, `COOKIE_SECURE=true`, and leave `OIDC_ALLOW_HTTP=false`. The latter is only for the loopback mock provider in automated tests, never a workaround for NAS certificates. Browser origins must stay consistent between app entry and callback.
|
||
|
||
## Sessions and offline work
|
||
|
||
The browser receives an opaque HttpOnly SameSite=Strict session cookie. A separate short-lived SameSite=Lax cookie binds the cross-site callback to the browser that initiated sign-in. Pending state/nonce/PKCE verifier are encrypted in one-use server transactions with a ten-minute expiry. The callback is excluded from service-worker navigation fallback and request logs omit its query string.
|
||
|
||
Access and refresh tokens stay AES-256-GCM encrypted in MariaDB, using the server-only encryption key. App session identifiers are stored as hashes. OIDC users are identified by a hash of `(issuer, subject)`, never linked to the recovery account by a matching username/email. Successful first SSO login provisions a staff profile only after group authorization succeeds; subsequent logins update the greeting and group snapshot from validated claims/UserInfo. Admitted users share the lab's operational data in this slice.
|
||
|
||
## Synology group access
|
||
|
||
[Synology's SSO Server documentation](https://kb.synology.com/en-af/PAS/help/SSOServer/sso_server_service?version=1_0) describes `groups` as the ID-token claim containing the groups to which the user belongs. Model Delivery always requests the `groups` scope and reads the configured claim from the validated ID token plus UserInfo response. In `/local-admin`, enter one group per line under **קבוצות מורשות**, then enable **הגבלת הכניסה לפי קבוצות Synology / AD**. A user is admitted when at least one returned group exactly matches an allowlist entry; comparison is Unicode-normalized and case-insensitive. Substrings and partial domain names do not match.
|
||
|
||
Keep the restriction disabled for the first diagnostic login. Open **החשבון שלי** after SSO login to see the group names actually returned by this Synology installation, copy the intended name into the allowlist, and then enable the restriction from the local recovery account. If Synology returns no group claim, group-restricted login is denied. The local recovery administrator is always exempt and can disable or repair the settings.
|
||
|
||
An unmatched user is rejected before a new app account or session is created. Saving changed SSO/group settings causes existing SSO sessions to fail their next online session check and require login. The server also checks each existing session against its last verified group snapshot. Provider-side membership changes with otherwise unchanged settings are learned during the next SSO login; an already-open app session can last up to seven days, and a fully offline device cannot learn a revocation. For immediate removal, change the allowlist (which invalidates existing SSO sessions) or disable the app user in the database until a dedicated user-management screen is built.
|
||
|
||
The app session has its own absolute seven-day lifetime from login, with no shorter idle timeout. If the provider issues no refresh token, expiration of its access token does not end this app session; no expired token is sent to a provider API. This supports Synology SSO configurations that return short-lived access tokens without refresh tokens. A fresh login is still required after the app session expires.
|
||
|
||
When an access token approaches expiry and a refresh token is present, the API renews it. A database lock prevents simultaneous requests from reusing a rotated refresh token. Network/provider errors keep the session for retry. An invalid refresh grant, disabled account, disabled SSO, changed SSO configuration or rejected stored group membership requires a fresh sign-in. Sync receives 401 and leaves its outbox untouched. Provider-side account/group changes are not polled for sessions without refresh tokens: the verified last-login group snapshot is checked against the current app allowlist on each authenticated request; changed provider membership is learned at a fresh login.
|
||
|
||
The local profile cache contains only user information and an absolute seven-day app-session expiry, never provider tokens or passwords. After an online login and app-shell installation, that unexpired profile permits offline work in the user's IndexedDB store. `offline_access` does not grant offline server access and is not needed for these saved local edits. After the local allowance expires, sign-in requires connectivity; pending data remains. Revocation cannot be learned while completely offline. Cached data is not encrypted or erased at logout; this is a trusted-device prototype, not a shared-device security boundary.
|
||
|
||
Logging out revokes this Model Delivery session, clears its cookie and cached profile, and retains account-specific outbox/data. It does not sign out of Synology or other SSO applications; the next SSO login may therefore be immediate. Provider-wide logout/revocation and recovery-password management UI are future work.
|
||
|
||
### Personal display name
|
||
|
||
Some Synology installations return only `sub`, `username` and `email` from UserInfo, with no full-name claim. Requesting `profile` cannot supply a field the provider does not support. In that case the initial greeting falls back to the username.
|
||
|
||
On 2026-09-04, a fresh live sign-in explicitly requested `name` (essential), `given_name` and `family_name` in both `id_token` and `userinfo` through the standard OIDC `claims` parameter. The validated ID token still contained only `aud`, `auth_time`, `email`, `exp`, `iat`, `iss`, `nonce`, `sub` and `username`; UserInfo still returned only `email`, `sub` and `username`. No name fields or Hebrew name were returned. The temporary request/capture code and encrypted snapshot were removed afterwards. This confirms the installed service did not supply those fields in that experiment; the protocol's claims parameter is optional ([OIDC Core](https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter)). Synology's documented custom attribute mappings are under SAML, rather than its OIDC application setup ([Synology application documentation](https://kb.synology.com/en-global/DSM/help/SSOServer/sso_server_application_list?version=7)).
|
||
|
||
Open **החשבון שלי** and set **שם לתצוגה** to choose the name used in the greeting. This online account setting is stored separately in `app_users.display_name_override`; the provider's original name remains in `display_name`. The preference takes precedence on session reads and future SSO logins. The username, issuer/subject identity, roles and operational records do not change. Successful saves update the cached profile immediately, including its next offline launch. `POST /auth/profile` accepts only `{displayName}`, requires a valid session, an allowed request origin/custom header and an `x-model-user` matching the session.
|
||
|
||
Choose **שימוש בשם הארגוני** to clear the personal override (`POST /auth/profile` with `{displayName:null}`) and use the directory/provider name again. This also refreshes the offline profile cache. It never edits the directory.
|
||
|
||
### Optional names from REZNIK Active Directory
|
||
|
||
The deployment target is the same NAS that hosts Synology SSO and Synology Directory Server (AD). Since the live OIDC responses omit name fields, the API can optionally read AD's `displayName` after a verified SSO login. Authentication, user identity and roles continue to come from the existing OIDC flow/app account; LDAP is used only for the greeting.
|
||
|
||
In `/local-admin`, open **שמות מתוך Active Directory** and configure:
|
||
|
||
| Setting | Value to supply |
|
||
| --- | --- |
|
||
| LDAPS URL | An internal NAS/domain-controller hostname reachable by the API, such as `ldaps://nas.example.internal:636`; the hostname must match the directory TLS certificate |
|
||
| NetBIOS domain | `REZNIK`, matching the verified SSO subject prefix |
|
||
| Base DN | The actual AD search root, for example `DC=example,DC=internal`; this example is not the discovered REZNIK domain |
|
||
| Bind DN / UPN | A dedicated account permitted to read the relevant AD user attributes |
|
||
| Password | Enter locally in the masked field; do not put it in source control or chat |
|
||
| CA certificate | Optional public PEM certificate chain if the directory uses an internal CA |
|
||
|
||
Save, enable when the connection is ready, and test a full SSO subject such as `REZNIK\tom.ruff`. The test reads the saved configuration and returns only the resolved name or a safe failure code. It does not update accounts. Sign in through SSO again to apply the directory name. A personal override still takes priority; use **שימוש בשם הארגוני** to clear it when desired.
|
||
|
||
Co-location alone does not establish connectivity: a container's `localhost` points to the container, not the NAS. Choose a reachable internal NAS address and verify that **Synology Directory Server's** LDAPS service and TLS certificate are configured. The DSM web certificate/SSO HTTPS endpoint is not evidence that LDAP TLS is configured. No directory ports were opened, NAS services changed, or production deployment performed. The development PC did not discover the REZNIK domain and the earlier public-host LDAPS connection timed out. The full AD DNS domain, internal connection details and read credentials have not been supplied; the working feature remains disabled.
|
||
|
||
Implementation uses [ldapts](https://github.com/ldapts/ldapts) 9.0.0 with direct LDAPS, TLS 1.2 minimum, certificate/hostname verification, and two-second connection and operation timeouts. Plain LDAP and StartTLS are not offered in this slice. A successful bind precedes one bounded search for an exact `sAMAccountName` under the configured base DN, with literal equality filters, user/person constraints and a two-entry limit. The verified issuer must match the one bound to the directory configuration and the subject must have the configured `DOMAIN\username` form. Bare usernames, other domains, duplicate entries and referrals are not accepted. Only `displayName`, `givenName`, `sn` and `sAMAccountName` are requested; first name plus surname is a fallback if displayName is empty. No groups, roles or directory writes are involved.
|
||
|
||
Migration 005 stores encrypted directory settings separately from OIDC settings and caches the resolved name with its configuration fingerprint. The precedence is personal override, cached name from the active matching directory configuration, then OIDC name/username. A directory failure does not prevent a valid OIDC sign-in; the last known name remains available. Changing or disabling directory settings makes old cached directory names ineligible on the next online session read. Offline devices retain their last saved profile until online refresh. Saving directory settings does not invalidate SSO sessions. Blank password preserves the existing credential only while the server URL and bind account remain unchanged. LDAP library errors and credentials are never returned or logged.
|
||
|
||
The first local administrator claims a copy of the pre-authentication IndexedDB cache once, preserving existing UUIDs/outbox entries. The original database remains as a backup. Open the app with that recovery account and synchronize any earlier unsent work before switching to SSO. Other accounts get separate device caches and pull the common server records. An account-ID request header prevents one account's queued mutations being sent under another account's cookie.
|
||
|
||
## Hidden page protection and API
|
||
|
||
The hidden path is only a convenience. All recovery endpoints reject non-loopback connections; settings/discovery additionally require an authenticated local administrator. Initial setup requires the random setup token. Login/setup use rate limits and passwords use salted scrypt. State-changing endpoints require a custom request header and reject disallowed browser origins.
|
||
|
||
At a future public reverse proxy, **deny `/local-admin` and `/api/local-admin/*` on the public listener** and provide a separate local/admin access path. The API sees the proxy's loopback address, so its loopback check alone cannot distinguish public clients behind that proxy. Do not expose this development proxy as a production access boundary.
|
||
|
||
| API path (browser adds `/api`) | Method / purpose |
|
||
| --- | --- |
|
||
| `/auth/session` | GET current session or SSO availability |
|
||
| `/auth/login` | POST begin OIDC; returns authorization URL |
|
||
| `/auth/callback` | GET process one-use code callback and redirect to app |
|
||
| `/auth/logout` | POST revoke current app session |
|
||
| `/local-admin/status` | GET whether recovery account setup remains open |
|
||
| `/local-admin/setup` | POST `{username,password,displayName,setupToken}` once |
|
||
| `/local-admin/login` | POST `{username,password}` for recovery account |
|
||
| `/local-admin/settings` | GET masked config; POST shared `SsoSettings` contract |
|
||
| `/local-admin/discover` | POST validate saved issuer/discovery metadata |
|
||
| `/local-admin/directory` | GET masked directory config; POST shared `DirectorySettings` contract |
|
||
| `/local-admin/directory/test` | POST `{subject}` to read one name using saved settings |
|
||
|
||
Blank client-secret input preserves the saved secret; choosing public-client authentication clears it. Saving changed SSO configuration invalidates existing SSO sessions on their next server request while leaving the recovery account usable.
|