Single sign-on (SSO)

Let your staff sign in to Timerise with Microsoft Entra ID or Google Workspace, and use the resulting token against the Timerise API.

Updated August 29, 2026ssoauthenticationentraazuregoogle-workspaceoidcsecurity

Overview

Single sign-on lets your staff authenticate to the Timerise API with your own corporate identity provider instead of a Timerise password.

You register an application in your own directory and give Timerise its credentials. Timerise provides one shared callback endpoint. On a successful sign-in, the API creates the user on the fly if needed and returns a short-lived access token - so everything downstream of authentication (roles, permissions, API keys) behaves exactly as it does for a password user.

Two things shape everything else:

  • This is API infrastructure, not just a dashboard feature. If you have built your own admin interface on the Timerise API, you are a first-class consumer: you register your own redirect URIs and receive a token that works directly as Authorization: Bearer against /v1. The Timerise dashboard is simply the default redirect target when you do not ask for another one.
  • The provider is pluggable. Microsoft Entra ID and Google Workspace are supported today, both over OpenID Connect.

You can enable more than one provider at once, and one person may use more than one. Someone who exists in both Entra and Google Workspace signs in to the same account either way, which makes a migration between directories possible without stranding anybody. Policy (domains, roles, redirect URIs) is shared by every provider, so which button a user presses can never change what they end up with.

Supported: Microsoft Entra ID, Google Workspace (both via OpenID Connect). Not supported: SAML, consumer social logins for admin access (see "Why consumer logins are excluded" below), end-customer sign-in on the booking page.

Set up your identity provider

Microsoft Entra ID

Performed once by an administrator of your Microsoft directory. This is a dedicated application for sign-in - it is separate from, and unrelated to, the Entra application used for Outlook calendar sync.

Register the application

  1. In the Microsoft Entra admin center, go to App registrations → New registration
  2. For supported account types, choose Accounts in this organizational directory only (single tenant)
  3. Under Redirect URI, choose platform Web and enter exactly the Timerise callback for your environment (see the table below)
  4. Do not tick ID tokens (used for implicit and hybrid flows) - many Entra tutorials tell you to, but it must stay off
EnvironmentRedirect URI
Productionhttps://api.timerise.io/sso/callback
Sandboxhttps://sandbox-api.timerise.io/sso/callback

The URI registered in Entra is always the Timerise callback - even if you run your own admin interface. Your own application's URL is registered on the Timerise side, in allowedRedirectUris. These are two different settings, and confusing them is the most common setup mistake.

Implicit and hybrid flows must stay off because Timerise uses the authorization code flow with a confidential client: the ID token goes to the Timerise server and never to the browser.

Create a client secret

Under Certificates & secrets → New client secret, record the Value (shown only once) and its expiry date. Both go into Timerise.

Entra client secrets always expire - 24 months at most. Record the expiry in Timerise so the connection can warn you before sign-in stops working.

Permissions

Under API permissions, add the Microsoft Graph delegated permissions openid, profile and email, then choose Grant admin consent. Without consent, every user is prompted individually the first time they sign in.

Optionally, under Enterprise applications → Properties, set Assignment required to Yes and assign only the users or groups who should reach Timerise. That is your own access control; the Timerise email-domain allowlist is a second, independent gate.

If some users have no email claim

Entra only emits the email claim when the directory's mail attribute is populated. Timerise falls back to preferred_username when that value is a valid email address. If neither is available, populate mail or add email as an optional claim on the token.

Google Workspace

Performed once by a Google Cloud project owner in your organization.

  1. In the Google Cloud console, open APIs & Services → Credentials → Create credentials → OAuth client ID and choose type Web application
  2. Under Authorised redirect URIs, add exactly the same Timerise callback as above (https://api.timerise.io/sso/callback, or the sandbox equivalent). The same caveat applies: this is the Timerise callback even if you run your own admin interface.
  3. Record the Client ID and Client secret
  4. On the OAuth consent screen, set the user type to Internal. This restricts sign-in to your own Workspace organization and is the equivalent of Entra's Assignment required.
  5. The openid, profile and email scopes are non-sensitive and need no verification review

Then add the provider in Timerise with your Workspace domain:

graphql
mutation { ssoProviderUpsert( organizationId: "org_123" type: GOOGLE label: "Contoso Google Workspace" enabled: true clientId: "1234.apps.googleusercontent.com" clientSecret: "the-client-secret" config: { hostedDomain: "contoso.com" } ) { providerId isConfigured } }

hostedDomain is not cosmetic. Google issues tokens for consumer accounts and for every Workspace tenant from the same issuer, so this value is the only thing that ties a sign-in to your organization. Google client secrets do not expire, so clientSecretExpiresAt can be omitted.

Configure SSO in Timerise

Two GraphQL mutations, both requiring the OWNER role on the organization.

First, the organization-level policy. It is shared by every provider on the connection, so permissions can never depend on which sign-in button a user pressed:

graphql
mutation { ssoConnectionUpsert( organizationId: "org_123" enabled: true emailDomains: ["contoso.com"] allowedRedirectUris: ["https://admin.contoso.com/sso/callback"] provisionMode: SELECTED_PROJECTS defaultProjectIds: ["project_abc"] defaultRole: STAFF organizationRole: USER ) { organizationId startUrl emailDomains domainsVerified } }

Then the provider credentials:

graphql
mutation { ssoProviderUpsert( organizationId: "org_123" type: ENTRA label: "Contoso Entra ID" enabled: true clientId: "11111111-2222-3333-4444-555555555555" clientSecret: "the-secret-value-from-entra" clientSecretExpiresAt: "2028-01-01T00:00:00.000Z" config: { tenantId: "6babcaa8-2ac9-4b12-9e2b-1fdf0e1e2f33" } ) { providerId isConfigured clientSecretHint } }

clientSecret is write-only. It is encrypted at rest and no output type can return it - only clientSecretHint, the last four characters. To change it, send a new value.

You can verify the credentials without involving a browser:

graphql
mutation { ssoProviderTest(organizationId: "org_123", providerId: "") { ok issuer message } }

Redirect URIs

allowedRedirectUris lists the URLs the sign-in flow may return a user to. Matching is by exact string equality - not by prefix, origin or path. https://admin.contoso.com/sso/callback does not permit https://admin.contoso.com/sso/callback/anything.

Rules: https only, except http://localhost and http://127.0.0.1 on any port, which are accepted in development and sandbox and rejected in production. No fragments, no credentials in the URL, no bare IP addresses, at most 10 entries of 512 characters each.

The Timerise dashboard's own callback is always permitted, and is used when redirect_uri is omitted.

Configuration reference

FieldMeaning
enabledMaster switch for the connection. When false, /sso/* returns 404 for this organization.
emailDomainsDomains eligible for provisioning, lowercase and without @. Public providers such as gmail.com are refused.
domainsVerifiedSet by Timerise staff after verifying domain ownership. Required before SSO may attach to an existing password account, and before enforcement can be enabled.
provisionModeSELECTED_PROJECTS (default) grants access to defaultProjectIds. ALL_ORG_PROJECTS grants every project in the organization, up to 100.
defaultRoleRole granted on each provisioned project. Defaults to STAFF. OWNER and SUPERADMIN are refused.
organizationRoleRole granted on the organization. Capped at MANAGER - an automatically provisioned user must not be able to delete the organization.
enforcedRefuses password login for the configured domains. See Enforcement and fallback below.
breakGlassEmailsAddresses exempt from enforcement.

See Team member roles for what each role can do.

The sign-in flow

GET  https://api.timerise.io/sso/{organizationId}/start?provider=…&redirect_uri=…
       → 302 to the identity provider

GET  https://api.timerise.io/sso/callback?code&state          (the provider returns here)
       → 302 to your redirect URI, with #ticket=… in the fragment

POST https://api.timerise.io/sso/exchange   {"ticket": "…"}
       → { customToken, expiresIn, firebaseConfig }

?provider= may be omitted when exactly one provider is enabled. ?redirect_uri= may be omitted to use the Timerise dashboard.

The session token is never placed in a URL. The callback hands over a single-use ticket valid for 60 seconds, carried in the URL fragment - fragments are never sent to a server, so the ticket stays out of access logs and out of any Referer header.

Use ssoDiscovery to decide whether to show an SSO button. It is public, because a sign-in page has to call it before any token exists. It only reports a connection whose domains have been verified - an unverified claim is first-come and unproven, so advertising it would let anyone claim a domain and have that company's own sign-in page route staff into a stranger's directory. Until verification completes, link to startUrl directly:

graphql
query { ssoDiscovery(email: "person@contoso.com") { ssoAvailable ssoRequired providers { providerId type label startUrl } } }

Integrate your own admin interface

Read the ticket from the fragment, clear it from history, and exchange it:

js
const ticket = new URLSearchParams(location.hash.slice(1)).get("ticket"); history.replaceState(null, "", location.pathname); const res = await fetch("https://api.timerise.io/sso/exchange", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ticket }), }); const { customToken, expiresIn, firebaseConfig } = await res.json();

You then have two options.

Use the token directly. The token is accepted by the Timerise API as-is:

js
await fetch("https://api.timerise.io/v1", { method: "POST", headers: { Authorization: `Bearer ${customToken}`, "Content-Type": "application/json" }, body: JSON.stringify({ query: '{ me(projectId: "") { userId email } }' }), });

No client library required. The token expires after one hour and cannot be refreshed - when it expires, send the user through /sso/{organizationId}/start again.

Or exchange it for a refreshable session. firebaseConfig contains the three public values needed for this (publishable identifiers, not secrets):

js
import { initializeApp } from "firebase/app"; import { getAuth, signInWithCustomToken } from "firebase/auth"; const auth = getAuth(initializeApp(firebaseConfig)); const { user } = await signInWithCustomToken(auth, customToken); const idToken = await user.getIdToken(); // refreshes automatically thereafter

Requests from a browser must come from an origin derived from one of your registered redirect URIs. /sso/exchange checks the Origin header server-side and answers 403 otherwise.

Security model

ConcernControl
Tokens from another directoryThe tid claim must equal the configured tenant, checked independently of the issuer, against a tenant-specific authority. This is the trust anchor; without it, any Microsoft tenant could impersonate a user.
Which identities may be provisionedThe email domain must be on the organization's allowlist. Combined with the tenant pin, this is the entire basis of trust.
Replay of a sign-inState and ticket are each consumed exactly once, inside a database transaction, and expire after 10 minutes and 60 seconds respectively.
Sign-in CSRF/start sets a HttpOnly, Secure, SameSite=Lax cookie; the callback rejects any response that does not carry it.
Redirecting a ticket somewhere elseExact-match redirect URIs only, resolved at /start and stored server-side. The callback never reads a redirect target from its own query string, and never redirects on failure.
Token substitutionThe ID token is only ever accepted from a server-to-server call to the provider's token endpoint, authenticated with the client secret. response_type=code with PKCE; implicit and hybrid flows are never used.
Forged signaturesOnly RS256 is accepted, against the provider's published keys. Key rotation is picked up automatically.
Stored credentialsClient secrets are encrypted at rest (AES-256-GCM) and are not returned by any query.
Abuse of public endpointsPer-IP and per-organization rate limits on /start, /callback and /exchange.

How a user is identified

The provider's subject is authoritative, not the email address. Directories reassign addresses when someone leaves, and people change address without changing identity; matching on email alone gets both cases wrong - in one direction handing a leaver's roles to whoever inherits their address.

On first sign-in the identity is recorded against the account as a (directory, subject) pair. An account can hold several such pairs. Afterwards:

  • The address changes, the subject does not - the same account is resolved and its email is updated. No duplicate is created.
  • The address is reused by someone new - refused with sso-subject-conflict. This is checked per directory, not against whichever directory was used last, so it still catches a reassigned Entra address on an account that also signs in through Google.
  • The same person appears from a second directory of the same organization - both are linked to one account, so someone present in both Entra and Google Workspace signs in to the same place either way. Requires domainsVerified, since linking two directories asserts that the organization controls both.
  • A directory belonging to a different organization - refused with sso-tenant-conflict.
  • The address already has a Timerise password account - refused unless domainsVerified is set. Otherwise any customer could add another company's domain to their allowlist, create that user in their own directory, and take over the account.

Why Google needs the hosted domain

Entra gives each directory its own issuer, so pinning the tenant is natural. Google does not: consumer accounts and every Workspace tenant in the world share accounts.google.com. The issuer therefore says nothing about which organization a user belongs to, and the hd claim is the only thing that does. A consumer account carries no hd at all, which is why its absence is treated exactly as harshly as a mismatch.

The email domain allowlist is not a substitute. Google marks a non-Gmail address as verified once its owner has clicked a confirmation link, so a personal Google account can legitimately hold a verified @contoso.com address without being part of the Contoso Workspace - a departed contractor, for instance. Only hd distinguishes the two.

Note also that the hd parameter Timerise sends on the authorization URL is only a hint for the account chooser. Google's own documentation is explicit that it enforces nothing, which is why the claim is re-checked on the returned token.

What SSO never does

It never sets a user's platform-level role, never grants OWNER on an organization, and never lowers an existing role. Access is added on each sign-in and removed only by an administrator.

Enforcement and fallback

With enforced: true, password login is refused for the configured domains: the login mutation returns SSO_REQUIRED before any credentials are checked.

Break glass

Client secrets expire. If enforcement is on and the secret has expired, nobody can sign in - including the owner who needs to update it. breakGlassEmails lists addresses that may still use a password. Configure at least one before enabling enforcement. Enforcement can only be switched on once domainsVerified is set.

Before a secret expires

Record clientSecretExpiresAt when you save a secret, and run ssoProviderTest periodically. Rotating a secret is a single ssoProviderUpsert with the new value.

Changing clientId or tenantId points the connection at a different application or directory, which invalidates the basis on which the domains were verified. Both domainsVerified and enforced are cleared automatically - enforcement must never outlive the verification it depends on, or password login would be refused for a domain no longer considered proven.

Provider support

The sign-in flow is provider-agnostic; only the authorization and token calls differ per provider. Each provider has a trust anchor - the claim that proves a token came from the tenancy you configured:

ProviderConfigurationTrust anchor
Entra IDtenantIdtid equals the configured tenant. No email_verified claim exists; trust comes from the tenant pin.
Google WorkspacehostedDomainhd equals the configured domain and email_verified is true. Without the hd pin, any Google account reaches the callback.
Generic OIDCissuerExact iss match and email_verified is true.

If you use another OpenID Connect provider, contact support - a generic OIDC adapter can be added without changing the sign-in flow, the provisioning rules or the GraphQL surface.

Why consumer logins are excluded

Facebook and similar consumer logins have no tenancy to pin, and their email address is optional and user-changeable. Combined with domain-based provisioning, anyone able to set an account's email to @yourcompany.com would gain admin access. Timerise therefore refuses any identity whose email address the provider does not vouch for. If consumer login is ever offered, it will belong to end-customer booking identity, where no roles are attached.

Troubleshooting

SymptomCause
/start returns 404The connection or the provider is disabled, or the organization ID is wrong. The response is deliberately identical in all three cases.
"The requested redirect URI is not registered"Not an exact match for an entry in allowedRedirectUris. Check for a trailing slash or an extra path segment.
"Specify which identity provider to use"More than one provider is enabled; pass ?provider=.
"This sign-in link has expired or has already been used"The 10-minute window elapsed, or the callback was replayed.
"This sign-in could not be verified"The browser-binding cookie was missing - usually because the sign-in finished in a different browser, or the cookie was blocked.
Sign-in fails after changing the Entra appChanging clientId or tenantId resets domainsVerified, which disables enforcement and account linking. Ask support to re-verify.
invalid_ticketThe ticket expired (60 seconds), was already exchanged, or the page was reloaded after the fragment was cleared.
origin_not_allowedThe calling page's origin is not derived from a registered redirect URI.
SSO_REQUIRED from loginEnforcement is on for that email domain. Use SSO, or add a break-glass address.
Users provisioned but see no projectsdefaultProjectIds is empty and provisionMode is SELECTED_PROJECTS.

Related articles