OAuth

How the IAM service handles OAuth social login, provider registration with schema or field-type maps, the standard profile mapping, user creation and deduplication, device trust, and the full request lifecycle.

The IAM service accepts OAuth social login through POST /auth/OAuth/:providerName. Providers are registered in the providers array in configuration(). Each entry defines how the incoming OAuth profile is validated and mapped to the internal user schema. The service handles user creation for first-time OAuth users and token issuance for returning users, but it does not perform the OAuth authorization code exchange itself.

The IAM service receives the already-resolved profile data from the OAuth provider. Your client application (or a backend-for-frontend layer) is responsible for redirecting the user to the provider, exchanging the authorization code for an access token, and fetching the profile. For a full OAuth client implementation with code exchange, PKCE, and state verification, see the auth-H3Client; module, which wraps this service and handles the complete OAuth flow.

Route

MethodPathBody limit
POST/auth/OAuth/:providerName4 KB

Middleware chain:

validateContentType('application/json'), express.json({ limit: '4kb' }), canary_id check, OAuthHandler

The body limit is 4 KB (compared to 1 KB for login and signup) to accommodate the larger profile payloads returned by some OAuth providers. The canary_id cookie must be present, as it is required to look up or create the visitor record that links the user to the Bot Detector fingerprint.

See Routes for how to mount the authentication router.


Registering a provider

Providers are defined in the providers array when calling configuration(). There are two ways to define a provider: with a custom Zod schema, or with a field-type map.

Option 1: Custom Zod schema

Use a full Zod schema when you need fine-grained validation of the provider's profile response.

server/config/auth.ts
import { configuration } from '@riavzon/auth'
import { z } from 'zod'

configuration({
  providers: [
    {
      name: 'google',
      schema: z.object({
        sub: z.string(),
        email: z.string().email(),
        name: z.string().optional(),
        picture: z.string().url().optional(),
        email_verified: z.boolean().optional(),
      }),
    },
  ],
  // ...
})

Option 2: Field-type map

Use fields for a simpler definition when the provider returns a flat profile object with predictable types. This approach is useful for JSON-based configuration files where Zod schemas cannot be serialized. Append ? to any type to mark the field optional.

configuration({
  providers: [
    {
      name: 'github',
      useStandardProfile: true,
      fields: {
        id: 'int',
        email: 'email',
        name: 'string?',
        avatar_url: 'url?',
        login: 'safeString?',
      },
    },
  ],
})

When using fields, the service builds a Zod schema from the field-type tokens at startup. The generated schema is functionally identical to a hand-written Zod schema.

Available field types

TypeDescription
'string' / 'string?'Any string
'email' / 'email?'Valid email address
'url' / 'url?'Valid URL (HTTPS protocol enforced)
'boolean' / 'boolean?'Boolean
'number' / 'number?'Floating-point number
'int' / 'int?'Integer
'safeString' / 'safeString?'String with XSS sanitization applied

Standard profile mapping

When useStandardProfile: true is set on a provider (or when using a custom schema that includes standard field names), the service automatically maps provider-specific field names to the internal StandardProfile interface after validation.

Provider fieldsMapped to
sub, id, user_idProvider subject ID (at least one is required)
picture, picture_url, avatar, avatar_urlProfile image URL
family_name, last_nameLast name
given_name, nameFirst name
emailEmail (always required)

email is required regardless of the mapping. The service rejects profiles that do not include a valid email address. At least one of sub, id, or user_id must also be present. The schema enforces this with a Zod .refine() check.

StandardProfile interface

interface StandardProfile {
  sub?: string | number
  id?: string | number
  user_id?: string | number
  email: string
  email_verified?: boolean
  name?: string
  given_name?: string
  family_name?: string
  last_name?: string
  avatar?: URL
  locale?: any
  location?: any
}

The avatar field is resolved from whichever profile key is present, checking in order: picture, picture_url, avatar, avatar_url. Only HTTPS URLs are accepted.


The authentication pipeline

Content-Type check

The request must be application/json. Mismatches return 403.

Provider lookup

The :providerName path parameter is matched against the registered providers. If no provider matches the name exactly, the controller returns 404.

Rate limiting (IP)

A union limiter keyed on req.ip combines a burst guard (1 point per second, blocks 5 minutes) with a slow guard (25 points per hour, blocks 30 minutes). See Rate Limiting for the guard() architecture.

Schema validation

The request body is extracted from req.body.userInfo, req.body.user, or req.body (in that order, to accommodate different OAuth client wrappers). The extracted profile is validated against the matched provider's Zod schema. If validation fails, the controller returns 400 with the Zod errors.

Profile mapping

The validated data is passed through the provider's mapProfile() function, which normalizes the provider-specific field names into the StandardProfile shape. The sub field is resolved from sub, id, or user_id, whichever is present. The name is taken from given_name or name.

Rate limiting (subject ID)

The provider subject ID (sub) is used as the key for a second rate limiter: 5 points per 5 minutes, blocking for 15 minutes. This prevents an attacker from hammering the OAuth endpoint with the same provider account.

Rate limiting (composite key)

A composite of ip + sub is used for a third rate limiter: 3 points per 10 minutes, blocking for 15 minutes.

User lookup or creation

The controller calls findUserByProvider(providerName, sub) to check if a user already exists with this provider and subject ID. The result determines the next step. See Returning users and New users below.


New users

When findUserByProvider returns no match, the controller calls createOauthUser(canary_id, mappedProfile, providerName). This function:

Look up the visitor

Queries the visitors table using the canary_id cookie to retrieve country, city, district, and visitor_id. If no visitor record exists, the function returns { success: false, noCanaryCookie: true }.

Derive the last name

The deriveLastNames utility resolves the last name by checking, in priority order: family_name, last_name, the portion of name after the first word (using given_name as a prefix if present). If none of these produce a value, the last name defaults to 'No lastname'.

Insert the user row

The function inserts a row into the users table:

ColumnSource
namegiven_name or name from the profile
last_nameDerived by deriveLastNames
emailFrom the profile (required)
avatarResolved URL from the profile
password_hashLiteral 'no_password'
providerThe provider name (e.g. 'google', 'github')
provider_idThe provider subject ID (sub)
remember_userfalse
terms_and_privacy_agreementfalse
countryFrom the visitor record
cityFrom the visitor record
districtFrom the visitor record
visitor_idFrom the visitor record (UUID foreign key)

The email column has a unique constraint. If a user with the same email already exists (e.g. they signed up with email/password previously), the database returns ER_DUP_ENTRY and the controller returns 409 { error: 'E-mail already registered' }.

Issue tokens

On success, generateRefreshToken and generateAccessToken are called to create a fresh session for the new user.

OAuth users have password_hash set to 'no_password'. They cannot use the login route (which requires Argon2 password verification) and must always authenticate through their OAuth provider. If you want to allow OAuth users to set a password later, build a "set password" flow that updates the password_hash column.

Returning users

When findUserByProvider finds an existing user row matching the provider and subject ID, the function generates a new refresh token and access token for that user without creating a new row.

Device trust

If trustUserDeviceOnAuth is true in the configuration and the Bot Detector assigned a new visitor_id for this request, the controller calls trustVisitor() to:

  1. Update the user's visitor_id foreign key to point at the visitor row for the current canary_id
  2. Call updateVisitors() from the Bot Detector to overwrite the fingerprint fields with the current request's geo and UA data
  3. Regenerate the access token with the new visitor_id

This re-baselines the fingerprint so that the anomaly detection engine compares future requests against the device the user most recently authenticated from. This is the same device-trust logic that runs during login.


Response

Success (201)

{
  "ok": true,
  "receivedAt": "2025-01-15T10:30:00.000Z",
  "accessToken": "<signed JWT>",
  "accessIat": "1705312200000"
}

Two cookies are set alongside the JSON response:

CookieValueOptions
iatDate.now() as stringhttpOnly, secure, sameSite: strict, path: /
sessionRaw refresh token (64 hex bytes)httpOnly, secure, sameSite: strict, domain from config

Error responses

StatusCondition
400Missing canary_id cookie, empty body, schema validation failure, no visitor record
404Provider name not found in registered providers
409Email already registered (duplicate email in users table)
429Rate limit exceeded on any of the three limiters

The OAuth request

The client calls POST /auth/OAuth/:providerName with the profile data in the JSON body. The :providerName parameter must exactly match the name registered in configuration().

// Client-side example (after exchanging the OAuth code for a profile)
await fetch('/auth/OAuth/google', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(googleProfile),
})

Body extraction

The controller extracts the profile from the request body using a fallback chain:

  1. req.body.userInfo (used by some OAuth client libraries)
  2. req.body.user (common in wrapper formats)
  3. req.body (raw profile at the top level)

This flexibility allows different OAuth client implementations to send the profile in whichever wrapper format they use.


Library exports

When using the IAM service as a library (not the standalone service), three OAuth-related functions are exported:

createOauthUser

Creates a new user from an OAuth profile. Call this in a custom OAuth handler when you want to control the flow.

import { createOauthUser } from '@riavzon/auth'

const result = await createOauthUser(
  req.cookies.canary_id,
  {
    email: profile.email,
    sub: profile.sub,
    name: profile.name,
    avatar: profile.picture,
  },
  'google'
)

if (result.duplicate) {
  // Email already registered
}

if (result.noCanaryCookie) {
  // No visitor record for the canary_id
}

if (result.success) {
  // result.accessToken and result.refreshToken are ready
}

findUserByOauthProvider

Looks up an existing user by provider name and provider subject ID without creating a new account.

import { findUserByOauthProvider } from '@riavzon/auth'

const result = await findUserByOauthProvider('google', googleProfile.sub)

if (result.user) {
  // result.id, result.visitor_id, result.accessToken, result.refreshToken
}

configureOauthProviders

Inspects or extends the registered provider list at runtime. Returns the array of configured provider definitions.

import { configureOauthProviders } from '@riavzon/auth'

const providers = configureOauthProviders()

Rate limiter reference

All OAuth rate limiters are configurable under rate_limiters in the configuration object. The defaults are:

LimiterKeyPointsWindowBlock duration
IP burstreq.ip1 / 1 second1 s5 min
IP slowreq.ip25 / 1 hour1 h30 min
Subject IDProvider sub5 / 5 minutes5 min15 min
Compositeip + sub3 / 10 minutes10 min15 min

Configuration reference

OptionLocationTypeDescription
providersRoot configarrayArray of provider definitions (Zod schema or field-type map)
trustUserDeviceOnAuthRoot configbooleanWhether OAuth login re-baselines the visitor fingerprint (default: false)
jwt.refresh_tokens.refresh_ttljwtnumberRefresh token lifetime in milliseconds
jwt.refresh_tokens.domainjwtstringCookie domain for the session cookie
jwt.access_tokens.expiresInjwtstringAccess token JWT expiry (default: '15m')

See Configuration for the full schema reference.

Logo