Complete reference for all Latch APIs, hooks, and utilities.
Access authentication state and sign-in/out functions.
Import:
import { useLatch } from '@/lib/latch';Signature:
function useLatch(): {
user: LatchUser | null;
isAuthenticated: boolean;
isLoading: boolean;
signIn: (returnTo?: string) => void;
signOut: (returnTo?: string) => void;
}Returns:
| Property | Type | Description |
|---|---|---|
user |
LatchUser | null |
Authenticated user info or null |
isAuthenticated |
boolean |
Whether user is authenticated |
isLoading |
boolean |
Whether session is being loaded |
signIn |
function |
Start OAuth flow (redirects to Azure AD) |
signOut |
function |
Sign out and clear session |
Example:
'use client';
import { useLatch } from '@/lib/latch';
export default function NavBar() {
const { user, isAuthenticated, isLoading, signIn, signOut } = useLatch();
if (isLoading) {
return <div>Loading...</div>;
}
if (!isAuthenticated) {
return <button onClick={() => signIn('/dashboard')}>Sign In</button>;
}
return (
<div>
<span>Welcome, {user?.name}</span>
<button onClick={() => signOut()}>Sign Out</button>
</div>
);
}Sign In with Custom Return URL:
// Redirect to /dashboard after sign-in
signIn('/dashboard');
// Redirect to current page (default)
signIn();Sign Out with Custom Return URL:
// Redirect to home after sign-out
signOut('/');
// Redirect to current page (default)
signOut();Get access token for Direct Token mode with auto-refresh.
Import:
import { useAccessToken } from '@/lib/latch';Signature:
function useAccessToken(options?: UseAccessTokenOptions): UseAccessTokenResultOptions (UseAccessTokenOptions):
| Option | Type | Default | Description |
|---|---|---|---|
autoRefresh |
boolean |
true |
Enable automatic token refresh |
refreshThreshold |
number |
300 |
Seconds before expiry to refresh |
retryOnFailure |
boolean |
true |
Retry failed refreshes with backoff |
maxRetries |
number |
3 |
Maximum retry attempts |
pauseWhenHidden |
boolean |
true |
Pause refresh when tab hidden |
Returns (UseAccessTokenResult):
| Property | Type | Description |
|---|---|---|
accessToken |
string | null |
Access token or null if not available |
isLoading |
boolean |
Whether token is being fetched |
error |
Error | null |
Error if fetch failed |
expiresAt |
number | null |
Unix timestamp when token expires |
refresh |
function |
Manually trigger refresh |
Example (Default Options):
'use client';
import { useAccessToken, getAzureEndpoints } from '@/lib/latch';
export default function ProfilePage() {
const { accessToken, isLoading, error } = useAccessToken();
const [user, setUser] = useState(null);
useEffect(() => {
if (!accessToken) return;
const endpoints = getAzureEndpoints('commercial', 'tenant-id');
fetch(`${endpoints.graphBaseUrl}/v1.0/me`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
.then(res => res.json())
.then(setUser);
}, [accessToken]);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{user?.displayName}</div>;
}Example (Custom Options):
const { accessToken, expiresAt, refresh } = useAccessToken({
autoRefresh: true,
refreshThreshold: 600, // Refresh 10 min before expiry
retryOnFailure: true,
maxRetries: 5, // More retries
pauseWhenHidden: false, // Keep refreshing when hidden
});
// Check time until expiry
if (expiresAt) {
const timeUntilExpiry = expiresAt - Date.now();
console.log(`Token expires in ${timeUntilExpiry}ms`);
}
// Manual refresh
const handleRefresh = async () => {
await refresh();
console.log('Token refreshed');
};Example (Disable Auto-Refresh):
const { accessToken, refresh } = useAccessToken({
autoRefresh: false, // Manual control
});
// Refresh on button click
<button onClick={refresh}>Refresh Token</button>Root provider for Latch. Must wrap your app.
Import:
import { LatchProvider } from '@/lib/latch';Props: None
Example:
// app/layout.tsx
import { LatchProvider } from '@/lib/latch';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<LatchProvider>
{children}
</LatchProvider>
</body>
</html>
);
}What it does:
- Fetches session on mount from
/api/latch/session - Provides
useLatch()context to children - Manages loading and authentication state
Protect routes by redirecting unauthenticated users.
Import:
import { LatchGuard } from '@/lib/latch';Props:
| Prop | Type | Default | Description |
|---|---|---|---|
children |
ReactNode |
required | Content to render when authenticated |
fallback |
ReactNode |
'Loading...' |
Content while checking auth |
redirectTo |
string |
'/' |
Where to redirect if not authenticated |
Example (Basic):
// app/dashboard/page.tsx
import { LatchGuard } from '@/lib/latch';
export default function DashboardPage() {
return (
<LatchGuard>
<h1>Protected Dashboard</h1>
<p>Only authenticated users see this.</p>
</LatchGuard>
);
}Example (Custom Fallback):
<LatchGuard
fallback={<div className="spinner">Checking authentication...</div>}
>
<Dashboard />
</LatchGuard>Example (Custom Redirect):
<LatchGuard redirectTo="/login">
<AdminPanel />
</LatchGuard>Load and validate Latch configuration from environment variables.
Import:
import { getLatchConfig } from '@/lib/latch';Signature:
function getLatchConfig(): LatchConfigReturns:
interface LatchConfig {
clientId: string; // Azure AD Client ID (UUID)
tenantId: string; // Azure AD Tenant ID (UUID)
cloud: LatchCloud; // 'commercial' | 'gcc-high' | 'dod'
scopes: string[]; // OAuth scopes
redirectUri: string; // OAuth callback URL
cookieSecret: string; // Cookie encryption secret
debug: boolean; // Debug mode enabled
}Example:
// app/api/latch/start/route.ts
import { getLatchConfig } from '@/lib/latch';
export async function GET() {
const config = getLatchConfig();
console.log(config.cloud); // 'commercial'
console.log(config.clientId); // '00000000-...'
console.log(config.scopes); // ['openid', 'profile', 'User.Read']
// ...
}Throws:
LatchErrorwith detailed message if any required env var is missing or invalid
Get Azure AD and Graph API endpoints for a cloud environment.
Import:
import { getAzureEndpoints } from '@/lib/latch';Signature:
function getAzureEndpoints(cloud: LatchCloud, tenantId: string): AzureEndpointsParameters:
| Parameter | Type | Description |
|---|---|---|
cloud |
'commercial' | 'gcc-high' | 'dod' |
Cloud environment |
tenantId |
string |
Azure AD Tenant ID |
Returns:
interface AzureEndpoints {
loginBaseUrl: string; // e.g., 'https://login.microsoftonline.com'
graphBaseUrl: string; // e.g., 'https://graph.microsoft.com'
authorizeUrl: string; // Full OAuth authorize URL
tokenUrl: string; // Full OAuth token URL
logoutUrl: string; // Full OAuth logout URL
jwksUri: string; // JWKS keys URL
}Example:
import { getAzureEndpoints } from '@/lib/latch';
const endpoints = getAzureEndpoints('commercial', 'tenant-id');
console.log(endpoints.graphBaseUrl);
// → 'https://graph.microsoft.com'
// Use in fetch calls
const response = await fetch(`${endpoints.graphBaseUrl}/v1.0/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});Cloud-Specific Endpoints:
| Cloud | Login URL | Graph URL |
|---|---|---|
commercial |
login.microsoftonline.com |
graph.microsoft.com |
gcc-high |
login.microsoftonline.us |
graph.microsoft.us |
dod |
login.microsoftonline.us |
dod-graph.microsoft.us |
Validate configuration before using it (useful for startup checks).
Import:
import { validateLatchConfig } from '@/lib/latch';Signature:
function validateLatchConfig(config: {
clientId?: string;
tenantId?: string;
cloud?: string;
cookieSecret?: string;
scopes?: string[];
}): voidExample:
// lib/startup-check.ts
import { validateLatchConfig } from '@/lib/latch';
try {
validateLatchConfig({
clientId: process.env.LATCH_CLIENT_ID,
tenantId: process.env.LATCH_TENANT_ID,
cloud: process.env.LATCH_CLOUD,
cookieSecret: process.env.LATCH_COOKIE_SECRET,
});
console.log('✅ Configuration valid');
} catch (error) {
console.error('❌ Configuration error:', error.message);
process.exit(1);
}Validation Checks:
- Client ID and Tenant ID are valid UUIDs
- Cloud is one of:
commercial,gcc-high,dod - Cookie secret is at least 32 characters
- Warns about weak secrets in production
Throws:
LatchErrorwith detailed suggestions if validation fails
⚠️ Status: Beta (v0.3.0+) - OBO functionality is production-ready but considered opt-in until core PKCE flow is GA. Import from@lance0/latch/obofor a lean, tree-shakeable bundle.
For a complete guide on OBO scenarios, see ON_BEHALF_OF_FLOW.md.
Recommended Import:
// Opt-in subpath export (tree-shakeable)
import { oboTokenForGraph, parseCAEChallenge } from '@lance0/latch/obo';
// Also available from main export (for backwards compatibility)
import { oboTokenForGraph } from '@lance0/latch';Exchange an incoming access token for a new token scoped to a different resource (middle-tier scenario).
Import:
import { exchangeTokenOnBehalfOf } from '@lance0/latch';Signature:
function exchangeTokenOnBehalfOf(
request: OBOTokenRequest
): Promise<OBOTokenResponse>Parameters (OBOTokenRequest):
| Property | Type | Required | Description |
|---|---|---|---|
userAssertion |
string |
Yes | Incoming access token from client |
clientId |
string |
Yes | Your API's client ID |
tenantId |
string |
Yes | Azure AD tenant ID |
cloud |
LatchCloud |
Yes | Cloud environment |
clientAuth |
object |
Yes | Client secret or certificate |
scopes |
string[] |
Yes | Scopes for downstream resource |
claims |
string |
No | CAE claims challenge (if retrying) |
allowedAudiences |
string[] |
No | Additional valid audiences |
requiredAzp |
string |
No | Required authorized party (azp) |
cacheOptions |
TokenCacheOptions |
No | Cache configuration override |
Returns (OBOTokenResponse):
interface OBOTokenResponse {
access_token: string; // Token for downstream resource
token_type: 'Bearer';
expires_in: number; // Seconds until expiry
expires_at?: number; // Unix timestamp
scope: string; // Granted scopes
refresh_token?: string; // Not typically returned for OBO
}Example (Client Secret):
import { exchangeTokenOnBehalfOf } from '@lance0/latch';
export async function GET(request: NextRequest) {
const bearerToken = request.headers.get('authorization')?.replace('Bearer ', '');
const oboResponse = await exchangeTokenOnBehalfOf({
userAssertion: bearerToken,
clientId: process.env.LATCH_CLIENT_ID!,
tenantId: process.env.LATCH_TENANT_ID!,
cloud: 'gcc-high',
clientAuth: {
clientSecret: process.env.LATCH_CLIENT_SECRET,
},
scopes: ['api://downstream/.default'],
});
// Use oboResponse.access_token to call downstream API
const downstreamResponse = await fetch('https://api.example.com/data', {
headers: { Authorization: `Bearer ${oboResponse.access_token}` }
});
}Example (Certificate - IL4/IL5):
import { exchangeTokenOnBehalfOf } from '@lance0/latch';
const oboResponse = await exchangeTokenOnBehalfOf({
userAssertion: bearerToken,
clientId: process.env.LATCH_CLIENT_ID!,
tenantId: process.env.LATCH_TENANT_ID!,
cloud: 'dod',
clientAuth: {
certificate: {
privateKey: process.env.LATCH_CERTIFICATE_PRIVATE_KEY!,
thumbprint: process.env.LATCH_CERTIFICATE_THUMBPRINT!,
x5c: process.env.LATCH_CERTIFICATE_X5C, // Optional
},
},
scopes: ['https://dod-graph.microsoft.us/.default'],
});Example (With CAE Claims):
try {
const oboResponse = await exchangeTokenOnBehalfOf({
userAssertion: bearerToken,
// ... other params
claims: 'eyJhY2Nlc3NfdG9rZW4iOnsibmJmIjp7ImVzc2VudGlhbCI6dHJ1ZSwidmFsdWUiOiIxNzI...',
});
} catch (error) {
if (error.code === 'LATCH_OBO_CAE_REQUIRED') {
// Return claims challenge to client
return NextResponse.json(
{ error: 'claims_required', claims: error.details?.claims },
{ status: 401 }
);
}
}Throws:
LATCH_OBO_INVALID_ASSERTION- Token validation failedLATCH_OBO_AUDIENCE_MISMATCH- Token not for this APILATCH_OBO_ISSUER_MISMATCH- Token from wrong cloud/tenantLATCH_OBO_EXCHANGE_FAILED- Azure AD token exchange failedLATCH_OBO_CAE_REQUIRED- Claims challenge requiredLATCH_OBO_MISSING_CLIENT_AUTH- No client secret or certificateLATCH_OBO_CERT_INVALID- Certificate malformed
Convenience wrapper for calling Microsoft Graph API via OBO.
Import:
import { oboTokenForGraph } from '@lance0/latch';Signature:
function oboTokenForGraph(
request: NextRequest,
options?: {
scopes?: string[];
claims?: string;
}
): Promise<string>Parameters:
| Parameter | Type | Description |
|---|---|---|
request |
NextRequest |
Next.js request with Authorization header |
options.scopes |
string[] |
Graph scopes (default: ['.default']) |
options.claims |
string |
CAE claims challenge |
Returns: Access token string for Microsoft Graph
Example:
import { oboTokenForGraph, getAzureEndpoints, getLatchConfig } from '@lance0/latch';
export async function GET(request: NextRequest) {
const config = getLatchConfig();
const endpoints = getAzureEndpoints(config.cloud, config.tenantId);
// Get token for Graph with specific scopes
const graphToken = await oboTokenForGraph(request, {
scopes: ['User.Read', 'Mail.Read'],
});
// Call Microsoft Graph
const graphResponse = await fetch(`${endpoints.graphBaseUrl}/v1.0/me/messages`, {
headers: { Authorization: `Bearer ${graphToken}` }
});
return NextResponse.json(await graphResponse.json());
}Sovereign Cloud Support:
Automatically uses correct Graph endpoint:
- Commercial:
https://graph.microsoft.com - GCC-High:
https://graph.microsoft.us - DoD:
https://dod-graph.microsoft.us
Get OBO token for a custom downstream API.
Import:
import { oboTokenForApi } from '@lance0/latch';Signature:
function oboTokenForApi(
request: NextRequest,
options: {
audience: string;
scopes?: string[];
claims?: string;
}
): Promise<string>Parameters:
| Parameter | Type | Description |
|---|---|---|
request |
NextRequest |
Next.js request with Authorization header |
options.audience |
string |
Downstream API's App ID URI or client ID |
options.scopes |
string[] |
Scopes (default: ['audience/.default']) |
options.claims |
string |
CAE claims challenge |
Returns: Access token string for downstream API
Example:
import { oboTokenForApi } from '@lance0/latch';
export async function GET(request: NextRequest) {
// Get token for downstream API
const downstreamToken = await oboTokenForApi(request, {
audience: 'api://my-downstream-api',
scopes: ['api://my-downstream-api/.default'],
});
// Call downstream API
const downstreamResponse = await fetch('https://api.example.com/data', {
headers: { Authorization: `Bearer ${downstreamToken}` }
});
return NextResponse.json(await downstreamResponse.json());
}Azure AD Setup:
- Go to your API's App Registration → API permissions
- Add permission → My APIs → Select downstream API
- Choose delegated permissions
- Grant admin consent
Get OBO token for an Azure Function with Easy Auth.
Import:
import { oboTokenForFunction } from '@lance0/latch';Signature:
function oboTokenForFunction(
request: NextRequest,
options: {
functionAppId: string;
scopes?: string[];
claims?: string;
}
): Promise<string>Parameters:
| Parameter | Type | Description |
|---|---|---|
request |
NextRequest |
Next.js request with Authorization header |
options.functionAppId |
string |
Function app's client ID or App ID URI |
options.scopes |
string[] |
Scopes (default: ['functionAppId/.default']) |
options.claims |
string |
CAE claims challenge |
Returns: Access token string for Azure Function
Example:
import { oboTokenForFunction } from '@lance0/latch';
export async function GET(request: NextRequest) {
const functionToken = await oboTokenForFunction(request, {
functionAppId: 'api://my-function-app',
});
const functionResponse = await fetch('https://my-func.azurewebsites.us/api/data', {
headers: {
Authorization: `Bearer ${functionToken}`,
'X-ZUMO-AUTH': functionToken, // For Easy Auth
}
});
return NextResponse.json(await functionResponse.json());
}Easy Auth Notes:
- Easy Auth validates tokens with its own client ID, not your app registration
- Use the Function App's client ID as
functionAppId - See ON_BEHALF_OF_FLOW.md for setup
Validate an incoming access token (verifies signature, audience, issuer, expiration).
Import:
import { validateAccessToken } from '@lance0/latch';Signature:
function validateAccessToken(
token: string,
expectedClientId: string,
expectedTenantId: string,
expectedCloud: LatchCloud,
options?: {
allowedAudiences?: string[];
requiredAzp?: string;
}
): Promise<ValidatedAccessToken>Parameters:
| Parameter | Type | Description |
|---|---|---|
token |
string |
Access token to validate |
expectedClientId |
string |
Your API's client ID |
expectedTenantId |
string |
Expected tenant ID |
expectedCloud |
LatchCloud |
Expected cloud environment |
options.allowedAudiences |
string[] |
Additional valid audiences |
options.requiredAzp |
string |
Required authorized party (prevents token forwarding) |
Returns (ValidatedAccessToken):
interface ValidatedAccessToken {
sub: string; // User's object ID
oid: string; // Object ID
tid: string; // Tenant ID
aud: string; // Audience
iss: string; // Issuer
azp?: string; // Authorized party
exp: number; // Expiration timestamp
nbf: number; // Not before timestamp
iat: number; // Issued at timestamp
scp?: string; // Scopes (space-separated)
roles?: string[]; // App roles
[key: string]: unknown; // Additional claims
}Example:
import { validateAccessToken, extractBearerToken } from '@lance0/latch';
export async function POST(request: NextRequest) {
const token = extractBearerToken(request.headers.get('authorization'));
if (!token) {
return NextResponse.json({ error: 'No token' }, { status: 401 });
}
try {
const claims = await validateAccessToken(
token,
process.env.LATCH_CLIENT_ID!,
process.env.LATCH_TENANT_ID!,
'gcc-high',
{
allowedAudiences: ['api://my-api', process.env.LATCH_CLIENT_ID!],
requiredAzp: process.env.EXPECTED_CLIENT_ID, // Prevent token forwarding
}
);
console.log(`Authenticated as: ${claims.sub}`);
console.log(`Scopes: ${claims.scp}`);
// Process request with validated claims
} catch (error) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
}Throws:
LATCH_OBO_INVALID_ASSERTION- Token signature invalid or expiredLATCH_OBO_AUDIENCE_MISMATCH- Token not for this APILATCH_OBO_ISSUER_MISMATCH- Token from wrong cloud/tenant
Extract bearer token from Authorization header.
Import:
import { extractBearerToken } from '@lance0/latch';Signature:
function extractBearerToken(authHeader: string | null): string | nullReturns: Token string or null if not a valid Bearer token
Example:
import { extractBearerToken } from '@lance0/latch';
export async function GET(request: NextRequest) {
const token = extractBearerToken(request.headers.get('authorization'));
if (!token) {
return NextResponse.json({ error: 'Missing token' }, { status: 401 });
}
// Use token...
}Handles:
- Missing header →
null - Malformed header →
null - Extra whitespace → Normalized
- Valid
Bearer <token>→<token>
Check if token is expiring within a threshold.
Import:
import { isTokenExpiringSoon } from '@lance0/latch';Signature:
function isTokenExpiringSoon(
expiresAt: number,
bufferSeconds?: number
): booleanParameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
expiresAt |
number |
required | Unix timestamp when token expires |
bufferSeconds |
number |
300 |
Seconds before expiry to consider "expiring soon" |
Returns: true if token expires within buffer period
Example:
import { isTokenExpiringSoon } from '@lance0/latch';
const expiresAt = Date.now() + 10 * 60 * 1000; // 10 minutes from now
if (isTokenExpiringSoon(expiresAt, 300)) {
console.log('Token expires in less than 5 minutes, should refresh');
}
if (isTokenExpiringSoon(expiresAt, 600)) {
console.log('Token expires in less than 10 minutes');
}For detailed CAE handling patterns, see ON_BEHALF_OF_FLOW.md.
Parse WWW-Authenticate header for CAE claims challenge.
Import:
import { parseCAEChallenge } from '@lance0/latch';Signature:
function parseCAEChallenge(
wwwAuthenticate: string | null
): CAEChallenge | nullReturns (CAEChallenge):
interface CAEChallenge {
claims: string; // Base64-encoded claims JSON
error?: string; // Error type (usually "insufficient_claims")
realm?: string; // Realm (usually empty)
}Example:
import { parseCAEChallenge, oboTokenForGraph } from '@lance0/latch';
export async function GET(request: NextRequest) {
const graphToken = await oboTokenForGraph(request);
const graphResponse = await fetch('https://graph.microsoft.us/v1.0/me', {
headers: { Authorization: `Bearer ${graphToken}` }
});
if (graphResponse.status === 401) {
const challenge = parseCAEChallenge(
graphResponse.headers.get('www-authenticate')
);
if (challenge) {
// Retry OBO with claims
const newToken = await oboTokenForGraph(request, {
claims: challenge.claims,
});
// Retry Graph call with new token
const retryResponse = await fetch('https://graph.microsoft.us/v1.0/me', {
headers: { Authorization: `Bearer ${newToken}` }
});
}
}
}Header Format:
WWW-Authenticate: Bearer realm="", error="insufficient_claims", claims="eyJhY2Nlc3..."
Returns null if not a CAE challenge (no claims parameter).
Build WWW-Authenticate header for CAE challenge (to return to client).
Import:
import { buildCAEChallengeHeader } from '@lance0/latch';Signature:
function buildCAEChallengeHeader(
claims: string,
error?: string,
realm?: string
): stringParameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
claims |
string |
required | Claims string from Azure AD |
error |
string |
'insufficient_claims' |
Error type |
realm |
string |
'' |
Realm |
Returns: Formatted WWW-Authenticate header value
Example:
import { buildCAEChallengeHeader } from '@lance0/latch';
export async function GET(request: NextRequest) {
try {
const token = await oboTokenForGraph(request);
// ... call Graph API
} catch (error: any) {
if (error.code === 'LATCH_OBO_CAE_REQUIRED') {
return NextResponse.json(
{ error: 'claims_required', claims: error.details?.claims },
{
status: 401,
headers: {
'WWW-Authenticate': buildCAEChallengeHeader(error.details?.claims),
}
}
);
}
}
}Output:
Bearer realm="", error="insufficient_claims", claims="eyJhY2Nlc3..."
Check if error is CAE-related.
Import:
import { isCAEError } from '@lance0/latch';Signature:
function isCAEError(error: any): booleanReturns: true if error is a CAE claims challenge
Example:
import { isCAEError, extractClaimsFromError } from '@lance0/latch';
try {
const token = await oboTokenForGraph(request);
// ...
} catch (error) {
if (isCAEError(error)) {
const claims = extractClaimsFromError(error);
return NextResponse.json(
{ error: 'claims_required', claims },
{ status: 401 }
);
}
throw error; // Other error
}Detects:
error.code === 'LATCH_OBO_CAE_REQUIRED'error.messagecontains "insufficient_claims"error.messagecontains "claims challenge"error.messagecontains "interaction_required"
Extract claims string from Latch OBO error.
Import:
import { extractClaimsFromError } from '@lance0/latch';Signature:
function extractClaimsFromError(error: any): string | nullReturns: Claims string or null if not a CAE error
Example:
import { extractClaimsFromError, buildCAEChallengeHeader } from '@lance0/latch';
try {
const token = await oboTokenForGraph(request);
} catch (error: any) {
const claims = extractClaimsFromError(error);
if (claims) {
// Return to client for retry
return NextResponse.json(
{ error: 'claims_required', claims },
{
status: 401,
headers: {
'WWW-Authenticate': buildCAEChallengeHeader(claims)
}
}
);
}
}Execute operation with automatic CAE retry detection.
Import:
import { withCAERetry } from '@lance0/latch';Signature:
function withCAERetry<T>(
operation: () => Promise<T>,
config?: CAERetryConfig
): Promise<T>Config (CAERetryConfig):
interface CAERetryConfig {
maxRetries?: number; // Default: 1
throwOnFailure?: boolean; // Default: true
}Example:
import { withCAERetry, oboTokenForGraph, parseCAEChallenge } from '@lance0/latch';
export async function GET(request: NextRequest) {
try {
const result = await withCAERetry(async () => {
const token = await oboTokenForGraph(request);
const response = await fetch('https://graph.microsoft.us/v1.0/me', {
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) {
const challenge = parseCAEChallenge(
response.headers.get('www-authenticate')
);
if (challenge) {
throw new Error('CAE_CHALLENGE:' + challenge.claims);
}
throw new Error('API error');
}
return response.json();
});
return NextResponse.json(result);
} catch (error: any) {
if (isCAEError(error)) {
const claims = extractClaimsFromError(error);
return NextResponse.json(
{ error: 'claims_required', claims },
{ status: 401 }
);
}
throw error;
}
}Important:
This helper detects and propagates CAE challenges. The client must handle the challenge and provide a new token with claims. The helper does NOT automatically retry with claims—it's for detection only.
Custom error class for all Latch errors.
Import:
import { LatchError } from '@/lib/latch';Signature:
class LatchError extends Error {
constructor(
public code: LatchErrorCode,
message: string,
public details?: unknown
)
}Properties:
| Property | Type | Description |
|---|---|---|
code |
LatchErrorCode |
Typed error code (see Types) |
message |
string |
Human-readable error message |
details |
unknown |
Optional additional context |
name |
'LatchError' |
Error name |
Example:
import { LatchError } from '@/lib/latch';
try {
// Some Latch operation
} catch (error) {
if (error instanceof LatchError) {
console.error(`Error [${error.code}]: ${error.message}`);
if (error.code === 'LATCH_REFRESH_TOKEN_MISSING') {
// Redirect to sign in
window.location.href = '/api/latch/start';
}
}
}Create enhanced LatchError with actionable suggestions.
Import:
import { createLatchError } from '@/lib/latch';Signature:
function createLatchError(
code: LatchErrorCode,
customMessage?: string,
details?: unknown
): LatchErrorExample:
import { createLatchError } from '@/lib/latch';
// Use built-in suggestions
throw createLatchError('LATCH_CLIENT_ID_MISSING');
// Or provide custom message
throw createLatchError(
'LATCH_TOKEN_EXCHANGE_FAILED',
'Custom context: Azure AD returned 401',
{ statusCode: 401 }
);Enhanced Messages Include:
- Step-by-step solutions
- Example configurations
- Links to documentation
- "Did you mean?" suggestions
Format error for logging (sanitized, no tokens).
Import:
import { formatErrorForLog } from '@/lib/latch';Signature:
function formatErrorForLog(error: unknown): stringExample:
import { formatErrorForLog } from '@/lib/latch';
try {
await refreshToken();
} catch (error) {
console.error(formatErrorForLog(error));
// → "[LATCH_TOKEN_REFRESH_FAILED] Token refresh failed..."
}Get user-facing error message (no sensitive data).
Import:
import { getUserSafeErrorMessage } from '@/lib/latch';Signature:
function getUserSafeErrorMessage(error: unknown): stringExample:
import { getUserSafeErrorMessage } from '@/lib/latch';
try {
await signIn();
} catch (error) {
// Show to user
alert(getUserSafeErrorMessage(error));
// → "Authentication error" (safe, generic)
// Log full error internally
console.error(error);
}Latch provides these API routes automatically:
Start OAuth flow (redirects to Azure AD).
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
returnTo |
string |
URL to redirect after sign-in (optional) |
Example:
<a href="/api/latch/start?returnTo=/dashboard">Sign In</a>What it does:
- Generates PKCE code verifier and challenge
- Generates random state and nonce
- Stores PKCE data in encrypted cookie (10 min expiry)
- Redirects to Azure AD authorize endpoint
OAuth callback handler (redirects after Azure AD).
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
code |
string |
Authorization code from Azure AD |
state |
string |
State parameter (CSRF protection) |
What it does:
- Validates state parameter (CSRF check)
- Exchanges code for tokens using PKCE
- Verifies ID token with JWKS
- Stores encrypted refresh token in cookie (7 days)
- Stores encrypted ID token claims in cookie (7 days)
- Redirects to
returnToURL
Refresh access token (for Direct Token mode).
Returns:
{
"access_token": "eyJ0eXAiOi...",
"expires_in": 3599
}Example:
const response = await fetch('/api/latch/refresh', { method: 'POST' });
const { access_token, expires_in } = await response.json();Sign out and clear session.
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
returnTo |
string |
URL to redirect after logout (optional) |
Example:
<a href="/api/latch/logout?returnTo=/">Sign Out</a>What it does:
- Clears all Latch cookies
- Redirects to Azure AD logout
- Azure AD redirects back to
returnToURL
Get current user session.
Returns:
{
"user": {
"sub": "00000000-0000-0000-0000-000000000000",
"email": "user@example.com",
"name": "John Doe",
"preferred_username": "john.doe@example.com",
"iat": 1234567890,
"exp": 1234571490
},
"isAuthenticated": true
}Or when not authenticated:
{
"user": null,
"isAuthenticated": false
}Example:
const response = await fetch('/api/latch/session');
const { user, isAuthenticated } = await response.json();
if (isAuthenticated) {
console.log(`Logged in as ${user.email}`);
}type LatchCloud = 'commercial' | 'gcc-high' | 'dod';interface LatchUser {
sub: string; // User's object ID
email?: string; // User's email
name?: string; // User's display name
preferred_username?: string; // User's UPN
iat: number; // Token issued at (Unix timestamp)
exp: number; // Token expires at (Unix timestamp)
}type LatchErrorCode =
// Configuration
| 'LATCH_CONFIG_MISSING'
| 'LATCH_CLIENT_ID_MISSING'
| 'LATCH_TENANT_ID_MISSING'
| 'LATCH_CLOUD_INVALID'
| 'LATCH_CLOUD_MISMATCH'
| 'LATCH_COOKIE_SECRET_MISSING'
// PKCE Flow
| 'LATCH_PKCE_MISSING'
| 'LATCH_STATE_MISSING'
| 'LATCH_STATE_MISMATCH'
| 'LATCH_NONCE_MISSING'
| 'LATCH_NONCE_MISMATCH'
| 'LATCH_CODE_MISSING'
// Token Operations
| 'LATCH_TOKEN_EXCHANGE_FAILED'
| 'LATCH_TOKEN_REFRESH_FAILED'
| 'LATCH_REFRESH_TOKEN_MISSING'
// Validation
| 'LATCH_INVALID_RETURN_URL'
| 'LATCH_ID_TOKEN_INVALID'
// Encryption
| 'LATCH_ENCRYPTION_FAILED'
| 'LATCH_DECRYPTION_FAILED'
// OBO Flow
| 'LATCH_OBO_INVALID_ASSERTION' // Incoming token validation failed
| 'LATCH_OBO_AUDIENCE_MISMATCH' // Token not for this API
| 'LATCH_OBO_ISSUER_MISMATCH' // Token from wrong cloud/tenant
| 'LATCH_OBO_EXCHANGE_FAILED' // Azure AD token exchange failed
| 'LATCH_OBO_CAE_REQUIRED' // Claims challenge required
| 'LATCH_OBO_MISSING_CLIENT_AUTH' // No client secret or certificate
| 'LATCH_OBO_CERT_INVALID' // Certificate malformed
| 'LATCH_OBO_AZP_MISMATCH'; // Authorized party mismatchThe session object returned by getServerSession() and requireServerSession():
interface LatchSession {
user: LatchUser | null; // User data from ID token (null if not authenticated)
isAuthenticated: boolean; // True if user has valid session
}User data from Azure AD ID token:
interface LatchUser {
sub: string; // Azure AD object ID (unique identifier)
oid: string; // Same as sub (Azure AD OID claim)
email?: string; // User email
name?: string; // User display name
preferred_username?: string; // Usually the email
iat: number; // Issued at timestamp
exp: number; // Expiration timestamp
// ... other Azure AD claims from ID token
}// In API route or Server Component
import { getServerSession } from '@lance0/latch';
const session = await getServerSession(process.env.LATCH_COOKIE_SECRET!);
if (session.isAuthenticated && session.user) {
// ✓ Access user properties through session.user
console.log(session.user.sub); // User ID
console.log(session.user.email); // User email
console.log(session.user.name); // User name
}// Using requireServerSession helper
import { requireServerSession } from '@lance0/latch';
export async function GET() {
try {
const session = await requireServerSession(process.env.LATCH_COOKIE_SECRET!);
// ✓ session.user is guaranteed to exist - no null checks needed
return Response.json({ userId: session.user.sub });
} catch (error) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
}// Using type guard
import { getServerSession, isLatchSession } from '@lance0/latch';
const session = await getServerSession(secret);
if (isLatchSession(session)) {
// ✓ TypeScript knows session.user is LatchUser (not null)
console.log(session.user.sub);
}const session = await getServerSession(secret);
// ❌ Properties are NOT on session directly - they're on session.user
if (session) {
console.log(session.sub); // undefined
console.log(session.email); // undefined
console.log(session.idToken); // undefined - this property doesn't exist
}When validating sessions in Next.js 16 proxy.ts, check session.sub:
// ✅ Correct - check sub claim
import { COOKIE_NAMES, unseal } from '@lance0/latch';
const cookie = request.cookies.get(COOKIE_NAMES.ID_TOKEN)?.value;
const session = await unseal(cookie, secret) as any;
if (!session || !session.sub) { // ✓ Check 'sub' from ID token claims
return NextResponse.redirect(new URL('/', request.url));
}// ❌ Wrong - idToken property doesn't exist
if (!session || !session.idToken) { // ✗ This property doesn't exist!
return NextResponse.redirect(new URL('/', request.url));
}Latch uses three encrypted cookies. Always use COOKIE_NAMES constants:
import { COOKIE_NAMES } from '@lance0/latch';
// Cookie name constants
COOKIE_NAMES.ID_TOKEN // → 'latch_id' (User session, ~300 bytes)
COOKIE_NAMES.REFRESH_TOKEN // → 'latch_rt' (Refresh token, ~2700 bytes)
COOKIE_NAMES.PKCE_DATA // → 'latch_pkce' (OAuth flow, ~250 bytes, temporary)
// ✅ Always use constants
const cookie = request.cookies.get(COOKIE_NAMES.ID_TOKEN);
// ❌ Never hardcode (names might change between versions)
const cookie = request.cookies.get('latch_id');Why use constants:
- Cookie names might change between versions
- Constants ensure consistency across your codebase
- TypeScript autocomplete works better
- Prevents typos
interface UseAccessTokenOptions {
autoRefresh?: boolean; // Default: true
refreshThreshold?: number; // Default: 300 (seconds)
retryOnFailure?: boolean; // Default: true
maxRetries?: number; // Default: 3
pauseWhenHidden?: boolean; // Default: true
}interface UseAccessTokenResult {
accessToken: string | null;
isLoading: boolean;
error: Error | null;
expiresAt: number | null;
refresh: () => Promise<void>;
}LATCH_CLIENT_ID=00000000-0000-0000-0000-000000000000
LATCH_TENANT_ID=11111111-1111-1111-1111-111111111111
LATCH_CLOUD=commercial
LATCH_COOKIE_SECRET=<32+ characters>LATCH_SCOPES=openid profile User.Read
LATCH_REDIRECT_URI=http://localhost:3000/api/latch/callback
LATCH_DEBUG=trueNEXT_PUBLIC_LATCH_CLOUD=commercial
NEXT_PUBLIC_LATCH_TENANT_ID=11111111-1111-1111-1111-111111111111- Troubleshooting: See TROUBLESHOOTING.md
- Authentication Modes: See AUTHENTICATION_MODES.md
- Security: See SECURITY.md
- Architecture: See ARCHITECTURE.md