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:

  1. Applications → Create App Integration → OIDC — OpenID Connect → Web Application.
  2. Sign-in redirect URI: https://nanosync.example.com/v1/auth/oidc/<org-uuid>/okta_acme/callback

    Replace <org-uuid> with your seeded default org UUID 00000000-0000-0000-0000-000000000001 (or another org if multi-tenant).

  3. Sign-out redirect URI: https://nanosync.example.com/
  4. Assignments: restrict to the groups who should be able to log in.
  5. 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

  1. GCP Console → APIs & Services → Credentials → Create Credentials → OAuth Client ID.
  2. Application type: Web application.
  3. Authorized redirect URI: https://nanosync.example.com/v1/auth/oidc/<org-uuid>/google/callback
  4. OAuth consent screen: restrict User type to Internal so only your Workspace users can log in.
  5. 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

  1. Azure Portal → Microsoft Entra ID → App registrations → New registration.
  2. Redirect URI (Web): https://nanosync.example.com/v1/auth/oidc/<org-uuid>/azure_acme/callback
  3. After creation, Certificates & secrets → Client secrets → New client secret. Copy the Value (not the ID).
  4. API permissions → Microsoft Graph → Delegated → openid, profile, email → Grant admin consent.
  5. 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'
);

How OIDC login flows end-to-end

  1. User visits /login. The page lists enabled IdPs via GET /v1/auth/idp.
  2. User clicks “Sign in with Okta”. Browser redirects to /v1/auth/oidc/<org>/okta_acme/login.
  3. Nanosync generates a state nonce, stores it in a short-lived cookie, redirects to Okta.
  4. Okta authenticates the user and redirects back to /v1/auth/oidc/<org>/okta_acme/callback?code=…&state=….
  5. Nanosync validates the state nonce (CSRF protection), exchanges the code for an ID token, verifies the signature.
  6. Claims are extracted into identity.ProviderResponse; UserService.UpsertFromIdentity creates or updates the user record.
  7. A session is issued and a nanosync_session cookie 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

SymptomLikely causeFix
Login page 404 on clickidp_configs.enabled = falseUPDATE idp_configs SET enabled = TRUE WHERE name = …
Callback returns ErrStateMismatchCookie was lost between login and callbackCheck that cookies aren’t being stripped by a reverse proxy; confirm SameSite=Lax is acceptable to the browser
Callback returns ErrInvalidIDTokenWrong issuer URL or client ID, or clock skewVerify the issuer_url matches what Okta/Google publishes (/.well-known/openid-configuration); check server clock
Startup log: client secret not foundsecrets_ref in idp_configs doesn’t match a secrets_kv rowns secret put with the exact same --key value
Startup log: idp loader: skipping oidc providerDecode error or unreachable issuerCheck 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