Smart Services

by wizewerx

Docs
PricingConsole

Auth

Hosted sign-in on your brand — one-time email codes, Google and LinkedIn. A real OIDC provider using PKCE, so there is no client secret.

What it is

Smart Services is an OpenID Connect provider: your app hands sign-in over to a hosted, branded page, and gets back a signed ID token describing the user. Your app is a public OIDC client using PKCE, so there is no client secret to store or rotate.

Before you start

Two values, neither of them secret, both from the Smart Services console → your app → Settings:

ValueWhat it is
client_idYour app's id. It is also the aud of every token we mint for you.
Issuerhttps://smart-services.io

Register your redirect URI on the same page. It is an exact-match allowlist — no wildcards, though a trailing slash is ignored.

Discovery lives at https://smart-services.io/.well-known/openid-configuration. Point your OIDC library at it and it will configure itself:

{
  "issuer": "https://smart-services.io",
  "authorization_endpoint": "https://smart-services.io/authorize",
  "token_endpoint": "https://smart-services.io/oauth/token",
  "userinfo_endpoint": "https://smart-services.io/oauth/userinfo",
  "end_session_endpoint": "https://smart-services.io/oauth/logout",
  "jwks_uri": "https://smart-services.io/.well-known/jwks.json",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "scopes_supported": ["openid", "email", "profile"],
  "token_endpoint_auth_methods_supported": ["none"],
  "code_challenge_methods_supported": ["S256"],
  "claims_supported": ["sub", "email", "email_verified", "name", "picture"]
}

token_endpoint_auth_methods_supported: ["none"] is the important line. PKCE with S256 is the only client authentication. plain is rejected.

Your app API key is a different credential and is never part of sign-in. It authenticates server-to-server calls — the /api/v1/auth/* endpoints below, plus email, jobs, storage and the rest. Both header forms work:

Authorization: Bearer <app key>
x-api-key: <app key>

The key is shown in the console → your app → Settings. Never send it as client_secret: it authenticates every call your app makes, so putting it in a browser-initiated redirect flow turns a sign-in leak into full compromise. The token endpoint ignores client_secret entirely, so a literal 'unused' placeholder is safer — it grants nothing.

Your first call

Confirm the provider is reachable and advertising what your library needs:

curl -s https://smart-services.io/.well-known/openid-configuration \
  | jq '{token_endpoint_auth_methods_supported, code_challenge_methods_supported, id_token_signing_alg_values_supported}'
const res = await fetch(
  'https://smart-services.io/.well-known/openid-configuration'
);
const config = await res.json();
console.log(config.token_endpoint_auth_methods_supported);

Response:

{
  "token_endpoint_auth_methods_supported": ["none"],
  "code_challenge_methods_supported": ["S256"],
  "id_token_signing_alg_values_supported": ["RS256"]
}

If you get HTML or a 404, check you are on smart-services.io — the old app.smart-services.io host was retired.

The NextAuth / Auth.js v5 provider block

// src/lib/auth.ts
import NextAuth from 'next-auth';

const issuer = process.env.SMART_SERVICES_AUTH_ISSUER!; // https://smart-services.io

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    {
      id: 'smart-services',
      name: 'Smart Services',
      type: 'oidc',
      issuer,
      wellKnown: `${issuer}/.well-known/openid-configuration`,
      clientId: process.env.SMART_SERVICES_AUTH_CLIENT_ID!,
      // Auth.js v5's OIDCConfig type requires this field. It is never sent.
      clientSecret: 'unused',
      client: { token_endpoint_auth_method: 'none' },
      authorization: { params: { scope: 'openid email profile' } },
      checks: ['pkce', 'state'],
      idToken: true,
      profile: (p) => ({
        id: p.sub,
        email: p.email,
        name: p.name ?? null,
        image: p.picture ?? null,
      }),
    },
  ],
  session: { strategy: 'jwt', maxAge: 30 * 24 * 60 * 60 },
});

Then export const { GET, POST } = handlers; in app/api/auth/[...nextauth]/route.ts, and register https://yourdomain.com/api/auth/callback/smart-services as your redirect URI. The last path segment is your provider id — change one and you must change the other.

On v4, use type: 'oauth' and omit clientSecret entirely; v4's types allow it. With better-auth, use genericOAuth with pkce: true and no clientSecret field.

What comes back

The token endpoint returns:

{
  "access_token": "eyJ...",
  "id_token": "eyJ...",
  "token_type": "Bearer",
  "expires_in": 3600
}

ID token claims: iss, sub, aud, iat, exp, email, email_verified, name, picture, and nonce when you sent one. email_verified is always true — the one-time code went to that inbox, which is the verification.

The rest of the endpoints

OIDC endpoints. No app key; the flow authenticates itself.

MethodPathWhat it does
GET/.well-known/openid-configurationDiscovery document.
GET/.well-known/jwks.jsonPublic keys. Verifies ID tokens and every webhook we send you.
GET/authorizeHosted branded sign-in page. Start the flow here.
POST/oauth/tokenExchange an authorization code for tokens. PKCE only.
GET, POST/oauth/userinfoFresh claims for a Bearer access token.
GET, POST/oauth/logoutRP-initiated logout. Burns anything in flight for that user.

Server-to-server. App API key required, and the auth service must be enabled on the app.

MethodPathWhat it does
POST/api/v1/auth/introspectVerify a token server-side and get the user back. No OIDC library needed.
POST/api/v1/auth/email-changeStart an email change for one of your users; returns a hosted confirmUrl.
POST/api/v1/auth/email-change/revertPut a user's address back, by appUserId or email.
POST/api/v1/auth/test-userCreate an ephemeral test user for automated sign-in.
DELETE/api/v1/auth/test-userRemove it again.

Browser-callable, no key (CORS is open; abuse is bounded by per-app, per-email rate limits):

MethodPathWhat it does
POST/api/v1/auth/otp/requestEmail a one-time code. Takes clientId, email.
POST/api/v1/auth/otp/verifyExchange the code for an authorization code. PKCE S256 required.
POST/api/v1/auth/email-change/verifyConfirm an email change with the emailed code.

/api/v1/auth/introspect is RFC 7662-shaped: an invalid token is {"active": false} with a 200, not an error. It returns active, sub, aud, exp and a user object of id, email, name, image. Tokens are app-scoped, so you can only introspect tokens minted for your own client_id.

Required: your auth-events endpoint

Every app using Smart Services sign-in must implement this. An integration without it is incomplete, and POST /api/v1/auth/email-change returns 400 rather than run a flow nothing is listening to.

It is one endpoint. Every server-to-server auth notification we will ever send arrives there, told apart by the type claim. New event types land on the same URL; you never add a second receiver.

Register it by passing authEventsUrl on your first POST /api/v1/auth/email-change call, or set it in the app's auth settings. The URL must be on an origin you have already registered as a redirect URI. That lock is deliberate: without it, a leaked app key could repoint your auth events at someone else's server and silence the one notification that tells you to kill a session.

The wire contract

POST <your authEventsUrl>
Authorization: Bearer <RS256 JWT>
Content-Type: application/json

The event is in the JWT claims, not the body. The body mirrors them for your logs and is not authoritative.

ClaimValue
isshttps://smart-services.io
audyour client_id — a token minted for another app cannot be replayed at yours
subthe AppUser.id; the same sub you stored at sign-in
jtistable across retries of one logical event, so you can dedupe
iat, expissued-at and expiry (5 minutes)
typeemail_change.completed or email_change.contested
datanewEmail, previousEmail and/or reason, depending on the event

There is no webhook secret, and there never will be. Every webhook is signed with the same keypair that signs your ID tokens, published at {issuer}/.well-known/jwks.json. You verify with the JWKS your OIDC library already fetches at sign-in. If a future integration doc asks you for a webhook secret, it is wrong — raise it rather than provisioning one.

// app/api/auth-events/route.ts
import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(
  new URL(`${process.env.SMART_SERVICES_AUTH_ISSUER}/.well-known/jwks.json`)
);

export async function POST(req: Request) {
  const token = (req.headers.get('authorization') ?? '')
    .match(/^Bearer\s+(.+)$/i)?.[1];
  if (!token) return new Response('unauthorized', { status: 401 });

  let claims: any;
  try {
    ({ payload: claims } = await jwtVerify(token, JWKS, {
      issuer: process.env.SMART_SERVICES_AUTH_ISSUER!,
      audience: process.env.SMART_SERVICES_AUTH_CLIENT_ID!,
    }));
  } catch {
    return new Response('bad token', { status: 401 });
  }

  const { type, sub, data } = claims;

  // `sub` never changes on an email change. Match on it, never on email.
  const user = await db.user.findUnique({ where: { authSubject: sub } });
  if (!user) return Response.json({ ok: true, applied: false });

  switch (type) {
    case 'email_change.completed':
      // The user drove this themselves. Update the cached address.
      await db.user.update({
        where: { id: user.id },
        data: { email: data.newEmail },
      });
      break;

    case 'email_change.contested':
      // Someone vetoed the change from the old address. `previousEmail` is the
      // address the account is on NOW — absent when it could not be restored.
      await db.user.update({
        where: { id: user.id },
        data: {
          ...(data?.previousEmail ? { email: data.previousEmail } : {}),
          sessionsValidFrom: new Date(), // THE POINT — see below
          lockedReason: data?.reason,
        },
      });
      if (data?.reason === 'email-change-contested-unrecoverable') {
        await alertSupport(user.id); // the address could not be given back
      }
      break;

    default:
      break; // ack unknown types so future events don't retry
  }

  return Response.json({ ok: true });
}

Why signing the user out is the whole point

The attack: someone uses a machine where your user is still signed in, and changes the account's email to their own. We mail the old address a decline link, so the real owner can undo it. But Smart Services cannot revoke your session cookie, and the access token (1 hour, no refresh) still carries the old email.

Skip the session invalidation and the owner reverts the address while the attacker keeps a live session in your app. You recovered the address and not the account. This event is the only signal you get.

Rules

  • Verify the JWT (iss, aud, exp) before acting, and read the event from its claims, not the body. An unverified request is an attacker asserting that a takeover was undone.
  • Idempotent, at-least-once. We try 3 times with a short backoff and a 5s timeout, then give up. The same event may arrive twice; jti is stable across retries.
  • Return 2xx fast, do work async. An auth operation never fails because your endpoint is down.
  • Ack unknown event types with 2xx so future events don't retry against you.

Starting an email change

const res = await fetch('https://smart-services.io/api/v1/auth/email-change', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${process.env.SMART_SERVICES_API_KEY}`,
  },
  body: JSON.stringify({
    email: user.email,   // their CURRENT address
    newEmail,
    authEventsUrl: 'https://you.example/api/auth-events',
    returnUrl: 'https://you.example/settings?email-changed=1',
    // Where a DECLINE lands. Someone who clicks "this wasn't me" has just
    // reported a possible takeover; the settings page is the wrong destination.
    declineReturnUrl: 'https://you.example/security',
  }),
});
const { confirmUrl, reference, appUserId, expiresAt } = await res.json();
redirect(confirmUrl); // branded confirm page, hosted by us

returnUrl and declineReturnUrl are matched by origin against your registered redirect URIs, and rejected with a 400 if they miss. The user lands back with ?email_change=completed — that is for UX only. Write your database from the webhook: the tab may be closed, and a decline never passes through your app at all.

Limits and errors

OIDC errors follow the spec: {"error": "invalid_grant"} or {"error": "invalid_client", "error_description": "..."}. Unknown client_id is 401 invalid_client; a bad code, a redirect_uri mismatch or a failed PKCE check is 400 invalid_grant. There is no request shape that redeems a code without a code_verifier — PKCE fails closed on a missing challenge.

The /api/v1/* endpoints return {"error": "..."} with the status in the HTTP code:

StatusMeaning
400Missing or malformed field; a returnUrl or authEventsUrl off your registered origins.
401Missing or invalid app API key.
403The auth service is not enabled for this app, or hosted auth is not configured.
404No such user for this app.

OTP requests are rate-limited per app and per email address inside the provider; the response is deliberately vague ("If this address can sign in, a code has been sent.") so it cannot be used to probe which addresses exist.

Auth is billed by live users — non-test accounts — not by a monthly meter, so nothing resets. At the ceiling, new sign-ups are refused and existing users keep signing in: hitting a billing limit must never lock your whole user base out. Which tier your app is on, and the current quota for it, are shown in the console on your app's page.

Gotchas

The issuer is smart-services.io. The old app.smart-services.io host was retired on 2026-09-21 and 404s every OIDC path. A venture still configured with it will look like a broken provider; it is a dead hostname.

sub is per-app, not portfolio-wide. It is the AppUser.id. The same human signing into two of your apps has two different sub values. email is the only thing that correlates across apps.

Store sub in its own column and match on it first, email second. Email is mutable; a user who changes theirs upstream must not become a second row. Keep a unique index on both. A signIn callback that overwrites user.id with your local id works, but leaves the sub stored nowhere — so there is no way to re-link after an email change. Don't do that.

There is no refresh token. The access token lasts 1 hour and is not renewed. Your own session cookie is what keeps a user signed in. Don't build refresh logic; there is nothing to refresh against.

Redirect URI mismatch is the most common failure. The last segment of the NextAuth callback path is your provider id. Register /api/auth/callback/smart-services for id: 'smart-services'. First thing to check when a callback fails.

Post-logout URIs match by origin, not exact path. That is deliberate: a landing page is not a code-bearing callback. Sign-in redirect URIs are still exact-match.

Logout does not clear a Smart Services session, and doesn't need to. The provider is stateless and sets no cookies, so clearing your own session cookie is sign-out. Calling /oauth/logout is optional, but it does real work when you pass id_token_hint: it burns any unconsumed authorization codes and OTPs for that user, so nothing in flight can be redeemed after sign-out.

No cross-app SSO. One issuer, but aud separates apps and sessions are per-app cookies. Signing into one app does not sign you into another. By design.

node:crypto imported in auth.ts breaks the client bundle. That file gets pulled into client-side bundles and webpack resolves the import statically. Keep node-only imports out of it.

Deriving secure cookies from NODE_ENV breaks under local TLS proxies. Derive secure from the actual request protocol instead.

Auth.js v5 error redirects behind a reverse proxy. v5 builds its error-redirect URL from the raw request, which behind a proxy can be the internal address. Set AUTH_URL explicitly and make sure the proxy forwards X-Forwarded-Proto.

Local dev: use a non-resolvable issuer for your bypass. https://local.smart-services.invalid (RFC 2606) can never validate against production even if a dev token leaks. Gate the bypass on an explicit env flag, never on NODE_ENV alone.

Don't reach for NODE_TLS_REJECT_UNAUTHORIZED=0 when a local TLS proxy upsets the discovery fetch. It disables verification for the whole process. Point NODE_EXTRA_CA_CERTS at your proxy's root certificate instead.