SSO setup
Nanosync supports per-org SSO via OIDC (modern IdPs) and SAML 2.0 (legacy enterprise). Configurations are stored in the idp_configs table; client secrets and SAML private keys are encrypted at rest in secrets_kv and referenced by secrets_ref.
The IdP loader at startup constructs concrete providers from the rows; misconfigured providers are logged and skipped so one bad row cannot brick the daemon.
OIDC quickstart
The OIDC flow is the same for every provider; what differs is the issuer URL, client registration steps, and the scope of profile data returned. Three concrete examples below.
1. Create an OIDC application in Okta
In the Okta Admin Console:
- Applications → Create App Integration → OIDC — OpenID Connect → Web Application.
- Sign-in redirect URI:
https://nanosync.example.com/v1/auth/oidc/<org-uuid>/okta_acme/callbackReplace
<org-uuid>with your seeded default org UUID00000000-0000-0000-0000-000000000001(or another org if multi-tenant). - Sign-out redirect URI:
https://nanosync.example.com/ - Assignments: restrict to the groups who should be able to log in.
- Copy the Client ID and Client Secret from the resulting page.
2. Store the client secret in nanosync
ns secret put \
--scope=org \
--scope-id=00000000-0000-0000-0000-000000000001 \
--key=sso/okta_acme/client_secret \
--value='<the-client-secret-from-okta>'3. Register the IdP config
INSERT INTO idp_configs (id, org_id, kind, name, enabled, config, secrets_ref) VALUES (
gen_random_uuid(),
'00000000-0000-0000-0000-000000000001',
'oidc',
'okta_acme',
TRUE,
jsonb_build_object(
'issuer_url', 'https://acme.okta.com',
'client_id', '<the-client-id-from-okta>',
'redirect_url', 'https://nanosync.example.com/v1/auth/oidc/00000000-0000-0000-0000-000000000001/okta_acme/callback',
'scopes', jsonb_build_array('openid', 'profile', 'email'),
'email_claim', 'email',
'name_claim', 'name',
'groups_claim', 'groups'
),
'sso/okta_acme/client_secret'
);4. Restart and verify
systemctl restart nanosync
# A startup log line confirms the loader picked it up:
# INFO loaded oidc provider: okta_acme org=…The login page now shows a “Sign in with Okta” button.
1. Create OAuth credentials in GCP
- GCP Console → APIs & Services → Credentials → Create Credentials → OAuth Client ID.
- Application type: Web application.
- Authorized redirect URI:
https://nanosync.example.com/v1/auth/oidc/<org-uuid>/google/callback - OAuth consent screen: restrict User type to Internal so only your Workspace users can log in.
- Copy the Client ID and Client Secret.
2. Store the client secret + register the IdP
ns secret put \
--scope=org \
--scope-id=00000000-0000-0000-0000-000000000001 \
--key=sso/google/client_secret \
--value='<the-client-secret>'INSERT INTO idp_configs (id, org_id, kind, name, enabled, config, secrets_ref) VALUES (
gen_random_uuid(),
'00000000-0000-0000-0000-000000000001',
'oidc',
'google',
TRUE,
jsonb_build_object(
'issuer_url', 'https://accounts.google.com',
'client_id', '<the-client-id>.apps.googleusercontent.com',
'redirect_url', 'https://nanosync.example.com/v1/auth/oidc/00000000-0000-0000-0000-000000000001/google/callback',
'scopes', jsonb_build_array('openid', 'profile', 'email'),
'email_claim', 'email',
'name_claim', 'name'
),
'sso/google/client_secret'
);Google’s hosted-domain (hd) claim isn’t enforced by nanosync; gate access at the OAuth consent screen (Internal user type) instead.
1. Register an application in Azure
- Azure Portal → Microsoft Entra ID → App registrations → New registration.
- Redirect URI (Web):
https://nanosync.example.com/v1/auth/oidc/<org-uuid>/azure_acme/callback - After creation, Certificates & secrets → Client secrets → New client secret. Copy the Value (not the ID).
- API permissions → Microsoft Graph → Delegated → openid, profile, email → Grant admin consent.
- From the Overview page, copy the Application (client) ID and Directory (tenant) ID.
2. Store the client secret + register the IdP
ns secret put \
--scope=org \
--scope-id=00000000-0000-0000-0000-000000000001 \
--key=sso/azure_acme/client_secret \
--value='<the-client-secret-value>'INSERT INTO idp_configs (id, org_id, kind, name, enabled, config, secrets_ref) VALUES (
gen_random_uuid(),
'00000000-0000-0000-0000-000000000001',
'oidc',
'azure_acme',
TRUE,
jsonb_build_object(
'issuer_url', 'https://login.microsoftonline.com/<tenant-id>/v2.0',
'client_id', '<application-client-id>',
'redirect_url', 'https://nanosync.example.com/v1/auth/oidc/00000000-0000-0000-0000-000000000001/azure_acme/callback',
'scopes', jsonb_build_array('openid', 'profile', 'email'),
'email_claim', 'preferred_username',
'name_claim', 'name'
),
'sso/azure_acme/client_secret'
);Azure’s preferred_username is the right email claim for most tenants. If your tenant uses a different attribute, override email_claim to match.
How OIDC login flows end-to-end
- User visits
/login. The page lists enabled IdPs viaGET /v1/auth/idp. - User clicks “Sign in with Okta”. Browser redirects to
/v1/auth/oidc/<org>/okta_acme/login. - Nanosync generates a state nonce, stores it in a short-lived cookie, redirects to Okta.
- Okta authenticates the user and redirects back to
/v1/auth/oidc/<org>/okta_acme/callback?code=…&state=…. - Nanosync validates the state nonce (CSRF protection), exchanges the code for an ID token, verifies the signature.
- Claims are extracted into
identity.ProviderResponse;UserService.UpsertFromIdentitycreates or updates the user record. - A session is issued and a
nanosync_sessioncookie is set. User is redirected to/.
JIT user provisioning: on first login, a users row is created with kind='user' and the email from the IdP. No further setup is needed before the user can log in.
JIT-provisioned users start with zero role bindings. They can log in but can’t do anything until an admin binds them to a role. See RBAC roles & bindings.
SAML 2.0
SAML rows in idp_configs are recognized at startup but the SP keypair storage + metadata serving lands in v1.1. For now you can register the row so the loader logs it:
INSERT INTO idp_configs (id, org_id, kind, name, enabled, config, secrets_ref) VALUES (
gen_random_uuid(),
'00000000-0000-0000-0000-000000000001',
'saml',
'okta_acme_saml',
TRUE,
jsonb_build_object(
'metadata_url', 'https://acme.okta.com/app/<app-id>/sso/saml/metadata'
),
''
);
Startup log:
INFO recognized saml provider okta_acme_saml — wiring deferred to v1.1
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Login page 404 on click | idp_configs.enabled = false | UPDATE idp_configs SET enabled = TRUE WHERE name = … |
Callback returns ErrStateMismatch | Cookie was lost between login and callback | Check that cookies aren’t being stripped by a reverse proxy; confirm SameSite=Lax is acceptable to the browser |
Callback returns ErrInvalidIDToken | Wrong issuer URL or client ID, or clock skew | Verify the issuer_url matches what Okta/Google publishes (/.well-known/openid-configuration); check server clock |
Startup log: client secret not found | secrets_ref in idp_configs doesn’t match a secrets_kv row | ns secret put with the exact same --key value |
Startup log: idp loader: skipping oidc provider | Decode error or unreachable issuer | Check config JSON syntax; verify network reachability from the daemon to the IdP |
Look at logs prefixed auth: sso for callback-specific traces:
journalctl -u nanosync -f | grep 'auth: sso'
What’s next
- RBAC roles & bindings — bind roles to your newly JIT-provisioned SSO users.
- Audit logging — every login becomes an audit event for compliance.