@ogcio/sag-client
v0.2.0
Published
Framework-agnostic client for the Secure API Gateway — authentication, health checks, and authenticated fetching
Readme
@ogcio/sag-client
Framework-agnostic TypeScript client for the Secure API Gateway. Handles authentication, health checks, and authenticated data fetching with built-in error handling for session expiry (401) and service unavailability (503).
Installation
pnpm add @ogcio/sag-client
# or
npm install @ogcio/sag-clientFor React hooks, also install peer dependencies:
pnpm add react swrPublishing
The package is published to npm as @ogcio/sag-client via release-please and Azure DevOps Pipelines.
How it works:
- Use Conventional Commits when committing changes to
packages/sag-client/:feat: ...— bumps a minor versionfix: ...— bumps a patch versionfeat!: ...orBREAKING CHANGE:— bumps a major version (once past v1)
- When changes are merged to
main, the pipeline runs release-please, which opens (or updates) a release PR that bumps the version inpackage.json, updatesCHANGELOG.md, and updates.release-please-manifest.json - When the release PR is merged, release-please creates a GitHub release and tag, and the pipeline publishes the package to npm
Configuration files (repo root):
release-please-config.json— release-please package configuration.release-please-manifest.json— current version tracking.azure/pipeline-sag-client.yaml— Azure DevOps pipeline (CI + release)
Entry Points
| Import path | Description | Dependencies |
|---|---|---|
| @ogcio/sag-client | Framework-agnostic core (SagClient class, auth functions, fetcher) | None (built-in fetch) |
| @ogcio/sag-client/react | React hooks and provider | react >= 19, swr >= 2 (peer deps) |
Core API (@ogcio/sag-client)
SagClient class
import { SagClient } from "@ogcio/sag-client"
const client = new SagClient({
gatewayUrl: "http://localhost:3333",
appName: "cars",
// Optional: custom handler for session expiry (default: redirect to sign-in)
onSessionExpired: () => console.log("Session expired"),
})
// Auth
const status = await client.checkAuth() // { authenticated, user, app, claims }
const health = await client.checkHealth() // { available: boolean }
client.signIn() // POST-redirect to sign-in
client.signIn({ connector: "social:mygovid" }) // Direct sign-in via social connector
client.signIn({ redirectUrl: "/dashboard" }) // Explicit post-login redirect
client.signOut() // POST-redirect to sign-out
await client.invalidateSession() // Clear server session (e.g. after onboarding)
// Organizations
const orgs = await client.fetchOrganizations() // [{ id, name, description?, roles }]
await client.selectOrganization("org_xxx") // Set current org (signed cookie)
const orgId = await client.getSelectedOrganization() // Get current org ID or null
await client.clearSelectedOrganization() // Clear org selection
// Fetching
const { data } = await client.fetch<Car[]>("/cars")
const { data: orgData } = await client.fetch<Car[]>("/cars", { organizationId: "org_xxx" })
// Or create a reusable fetcher (for SWR, TanStack Query, etc.)
const fetcher = client.createFetcher<Car[]>()
const result = await fetcher("http://localhost:3333/cars")Standalone functions
For one-off calls without creating a client instance:
import {
checkAuth, checkHealth, signIn, signOut, invalidateSession,
fetchOrganizations, selectOrganization, getSelectedOrganization, clearSelectedOrganization,
createGatewayFetcher,
} from "@ogcio/sag-client"
const status = await checkAuth("http://localhost:3333")
const health = await checkHealth("http://localhost:3333")
signIn("http://localhost:3333", "cars")
signIn("http://localhost:3333", "cars", { connector: "social:mygovid" })
signOut("http://localhost:3333", "cars")
await invalidateSession("http://localhost:3333")
// Organization management
const orgs = await fetchOrganizations("http://localhost:3333")
await selectOrganization("http://localhost:3333", "org_xxx")
const orgId = await getSelectedOrganization("http://localhost:3333")
await clearSelectedOrganization("http://localhost:3333")Error handling
The fetcher throws SagFetchError with status and code properties:
import { SagFetchError } from "@ogcio/sag-client"
try {
await client.fetch("/cars")
} catch (err) {
if (err instanceof SagFetchError) {
console.log(err.status) // 401, 503, etc.
console.log(err.code) // "SESSION_EXPIRED", "LOGTO_UNAVAILABLE", etc.
console.log(err.message) // User-friendly message from the gateway
}
}M2M (Machine-to-Machine) requests
When the gateway service is configured for M2M, you can request that the gateway uses client_credentials instead of the user's session token by passing actorType: "m2m":
// Fetch with M2M token via SagClient
const { data } = await client.fetch<Notification[]>("/notifications", {
actorType: "m2m",
})
// Or via the standalone fetcher factory
const fetcher = createGatewayFetcher("http://localhost:3333", "cars", undefined, {
actorType: "m2m",
})This sends the X-Request-Actor-Type: m2m header. The user must still be authenticated (M2M is not anonymous). The header name is also available as a constant:
import { ACTOR_TYPE_HEADER } from "@ogcio/sag-client"
// ACTOR_TYPE_HEADER === "X-Request-Actor-Type"Organization-Scoped Requests
When the gateway has a selected organization (via cookie or explicit header), it uses getOrganizationToken(orgId) instead of the default getAccessToken(resource). You can pass organizationId to any fetch call:
// Via SagClient
const { data } = await client.fetch<Message[]>("/messaging", {
organizationId: "org_xxx",
})
// Via standalone fetcher
const fetcher = createGatewayFetcher("http://localhost:3333", "messaging", undefined, {
organizationId: "org_xxx",
})This sends the X-Organization-Id: org_xxx header. The header name is available as a constant:
import { ORGANIZATION_ID_HEADER } from "@ogcio/sag-client"
// ORGANIZATION_ID_HEADER === "X-Organization-Id"Role Detection Utilities
Pure functions for determining citizen / public-servant / onboarding status from Logto ID-token claims:
import {
isCitizen,
isPublicServant,
isInactivePublicServant,
isCitizenOnboarded,
CONNECTOR_MYGOVID,
CONNECTOR_ENTRAID,
ALLOWED_SIGNIN_METHODS,
DEFAULT_PUBLIC_SERVANT_ROLES, // ["Organisation Admin", "Organisation Member"]
ORG_ROLE_ADMIN, // "Organisation Admin"
ORG_ROLE_MEMBER, // "Organisation Member"
} from "@ogcio/sag-client"
// After checking auth:
const status = await client.checkAuth()
if (status.authenticated) {
const { organization_roles, roles } = status.claims
// Use the platform defaults for citizen-facing apps:
const citizen = isCitizen(organization_roles, DEFAULT_PUBLIC_SERVANT_ROLES)
const publicServant = isPublicServant(organization_roles, DEFAULT_PUBLIC_SERVANT_ROLES)
// Or use custom roles for admin apps:
const isMessagingPS = isPublicServant(organization_roles, ["Messaging Public Servant"])
const inactive = isInactivePublicServant(organization_roles)
const onboarded = isCitizenOnboarded(roles)
}Onboarding Helpers
Build redirect URLs for the onboarding and wrong-login-method flows:
import { buildOnboardingRedirectUrl, buildWrongLoginMethodRedirect } from "@ogcio/sag-client"
// Redirect to onboarding (after invalidating the session)
const url = buildOnboardingRedirectUrl({
profileUrl: "https://profile.example.com",
currentPath: "/messages/123",
gatewayUrl: "http://localhost:3333",
appName: "messaging",
appBaseUrl: "https://messaging.example.com", // where the user lands after auth
connector: "social:mygovid", // optional
})
window.location.href = url
// Redirect to wrong-login-method error page
const errorUrl = buildWrongLoginMethodRedirect({
profileUrl: "https://profile.example.com",
currentUrl: "https://messaging.example.com/messages/123",
})Onboarding Flow (End-to-End)
For citizen-facing React applications, the recommended approach is the useOnboardingGuard hook (see the React API section below). It encapsulates all role checks, session invalidation, redirect URL construction, and infinite-loop prevention in a single call.
For non-React or server-side consumers, you can implement the same logic manually with the standalone helpers:
import {
ALLOWED_SIGNIN_METHODS,
CONNECTOR_MYGOVID,
buildOnboardingRedirectUrl,
buildWrongLoginMethodRedirect,
isCitizen,
isCitizenOnboarded,
} from "@ogcio/sag-client"
// After checking auth (e.g. in a page guard):
const status = await client.checkAuth()
if (!status.authenticated) return
const { roles, organization_roles } = status.claims
// 1. Wrong sign-in method → redirect to error page
const signinMethod = status.claims.signinMethod // if available
if (signinMethod && !ALLOWED_SIGNIN_METHODS.includes(signinMethod)) {
window.location.href = buildWrongLoginMethodRedirect({
profileUrl: PROFILE_URL,
currentUrl: window.location.href,
})
return
}
// 2. Not a citizen → skip (or redirect to admin app)
if (!isCitizen(organization_roles, PUBLIC_SERVANT_ROLES)) {
return
}
// 3. Citizen but not onboarded → invalidate session and redirect
if (!isCitizenOnboarded(roles)) {
await client.invalidateSession()
window.location.href = buildOnboardingRedirectUrl({
profileUrl: PROFILE_URL,
currentPath: window.location.pathname,
gatewayUrl: GATEWAY_URL,
appName: APP_NAME,
appBaseUrl: APP_BASE_URL, // e.g. "http://localhost:3000"
connector: CONNECTOR_MYGOVID,
})
return
}
// 4. Citizen is onboarded → proceed normallyThe redirect chain is:
app → profile-service/onboarding?source=<gateway-sign-in-url>
→ (user completes onboarding, gains "Onboarded citizen" role)
→ GET gateway/auth/sign-in?app=<name>&redirectUrl=<app-url>&connector=social:mygovid
→ Logto OIDC flow (fresh claims)
→ gateway/auth/callback
→ app (original page, now with updated roles)Note:
appBaseUrlis the consuming application's URL (e.g.http://localhost:3000), not the gateway URL. This ensures the user lands back on the correct app page after the post-onboarding sign-in completes.
Types
import type {
ActorType, // "user" | "m2m"
AuthClaims, // { roles, organizations, organization_roles, signinMethod? }
AuthStatus,
AuthUser,
GatewayFetchOptions, // { actorType?, organizationId? }
OrganizationInfo, // { id, name, description?, roles }
SagClientConfig,
SignInOptions, // { connector?, redirectUrl? }
UseAuthResult,
UseOnboardingGuardOptions,
UseOnboardingGuardResult,
UsePublicServantGuardOptions,
UsePublicServantGuardResult,
OnboardingRedirectParams,
WrongLoginMethodParams,
} from "@ogcio/sag-client"React API (@ogcio/sag-client/react)
Provider
Wrap your app with SagClientProvider:
import { SagClientProvider } from "@ogcio/sag-client/react"
export function Providers({ children }) {
return (
<SagClientProvider gatewayUrl="http://localhost:3333" appName="cars">
{children}
</SagClientProvider>
)
}Hooks
useAuth
import {
useAuth,
useSagClient,
CONNECTOR_MYGOVID,
} from "@ogcio/sag-client/react"
function MyComponent() {
const {
authenticated,
user,
claims, // { roles, organizations, organization_roles, signinMethod? }
loading,
logtoAvailable,
signIn, // accepts optional SignInOptions
signOut,
invalidateSession,
refresh,
} = useAuth()
// Sign in with a specific connector
const handleCitizenSignIn = () => signIn({ connector: CONNECTOR_MYGOVID })
// Direct client access (advanced)
const client = useSagClient()
}useGatewayFetch
import { useGatewayFetch } from "@ogcio/sag-client/react"
// SWR-based data fetching
const { data, error, isLoading, refresh } = useGatewayFetch<Car[]>("/cars")
// Conditional fetching
const { data: detail } = useGatewayFetch<Car>(`/cars/${id}`, { enabled: !!id })
// M2M fetching (sends X-Request-Actor-Type: m2m header)
const { data: notifications } = useGatewayFetch<Notification[]>("/notifications", {
actorType: "m2m",
})useOnboardingGuard
Encapsulates the full citizen onboarding check for React applications. Must be used inside a SagClientProvider. Reads gatewayUrl and appName from the provider context automatically.
import {
useOnboardingGuard,
useAuth,
CONNECTOR_MYGOVID,
} from "@ogcio/sag-client/react"
function ShellContent({ children }) {
// publicServantRoles defaults to DEFAULT_PUBLIC_SERVANT_ROLES
// (["Organisation Admin", "Organisation Member"])
const { resolved } = useOnboardingGuard({
profileUrl: "http://localhost:3001",
appBaseUrl: "http://localhost:3000",
connector: CONNECTOR_MYGOVID, // optional
// publicServantRoles: ["Custom Role"], // override for admin apps
// debounceMs: 30_000, // optional, default: 30000
})
const { user, signIn, signOut } = useAuth()
if (!resolved) return <Loading />
return user ? <App>{children}</App> : <SignInButton />
}Options:
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| profileUrl | string | Yes | - | Profile service base URL |
| appBaseUrl | string | Yes | - | Consuming application's base URL (for post-auth redirect) |
| publicServantRoles | string[] | No | DEFAULT_PUBLIC_SERVANT_ROLES | Organisation roles that identify a public servant |
| connector | string | No | undefined | Logto directSignIn connector (e.g. "social:mygovid") |
| debounceMs | number | No | 30000 | Time window (ms) to prevent redirect loops |
Return value:
| Property | Type | Description |
|----------|------|-------------|
| resolved | boolean | true when the onboarding check has passed; false while loading or redirecting |
Behaviour:
- User is onboarded (
isCitizenOnboarded) →resolved = true - User is not a citizen →
resolved = true(let them through) - Recent redirect within
debounceMs→resolved = true(prevent loops) - Wrong sign-in method → redirect to profile service error page
- Citizen not onboarded →
invalidateSession()then redirect to profile service onboarding page
Important: Do not render data-fetching children (e.g.
useGatewayFetch) untilresolvedistrueto prevent API requests from racing with session invalidation.
usePublicServantGuard
Access guard hook for public-servant-facing (admin) applications. Checks if the authenticated user is an active public servant and optionally redirects unauthorized users.
import { usePublicServantGuard, useAuth } from "@ogcio/sag-client/react"
function AdminShell({ children }) {
const { resolved, authorized, isInactive } = usePublicServantGuard({
// Redirect citizens to the citizen-facing app (optional)
unauthorizedRedirectUrl: "https://citizen-app.example.com",
// Redirect inactive PS to a specific page (optional)
inactiveRedirectUrl: "https://admin.example.com/inactive",
// publicServantRoles: ["Custom Admin Role"], // override defaults
})
const { user } = useAuth()
if (!resolved) return <Loading />
if (!authorized) return <AccessDenied />
return <>{children}</>
}Options:
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| publicServantRoles | string[] | No | DEFAULT_PUBLIC_SERVANT_ROLES | Organisation roles that identify an active public servant |
| inactiveRedirectUrl | string | No | undefined | URL to redirect inactive PS users to. If omitted, they pass through with isInactive: true. |
| unauthorizedRedirectUrl | string | No | undefined | URL to redirect non-PS users to. If omitted, they pass through with authorized: false. |
Return value:
| Property | Type | Description |
|----------|------|-------------|
| resolved | boolean | true once the guard check has completed |
| authorized | boolean | true when the user is an active public servant |
| isInactive | boolean | true when the user is an inactive public servant |
useGatewayFetch with organization scope
import { useGatewayFetch } from "@ogcio/sag-client/react"
// Fetch with an organization-scoped token
const { data } = useGatewayFetch<Message[]>("/messaging", {
organizationId: "org_xxx",
})Role detection utilities
Role detection functions are also re-exported from the React entry point for convenience:
import {
isCitizen,
isCitizenOnboarded,
isPublicServant,
CONNECTOR_MYGOVID,
ALLOWED_SIGNIN_METHODS,
DEFAULT_PUBLIC_SERVANT_ROLES,
ORG_ROLE_ADMIN,
ORG_ROLE_MEMBER,
ONBOARDING_PATH,
ONBOARDING_SOURCE_PARAM,
WRONG_LOGIN_METHOD_PATH,
WRONG_LOGIN_RETURN_URL_PARAM,
} from "@ogcio/sag-client/react"Package Structure
src/
index.ts # Core entry point
client.ts # SagClient class
auth.ts # Standalone auth functions (signIn, signOut, invalidateSession, etc.)
fetcher.ts # Gateway fetcher factory
roles.ts # Role detection utilities (isCitizen, isPublicServant, etc.)
onboarding.ts # Onboarding & wrong-login-method URL builders
types.ts # All type definitions
react/
index.ts # React entry point (re-exports roles & onboarding for convenience)
provider.tsx # SagClientProvider + useSagClient
use-auth.ts # useAuth hook (with claims, connector-aware signIn, invalidateSession)
use-gateway-fetch.ts # useGatewayFetch hook
use-onboarding-guard.ts # useOnboardingGuard hook (citizen onboarding guard)
use-public-servant-guard.ts # usePublicServantGuard hook (admin app access guard)