Integrating Headless Login & Consent
This guide walks you through implementing custom Login, Consent, and Logout user interfaces that integrate with Shyntr's headless architecture.
Prerequisites
Before starting, ensure you have:
- Shyntr running (locally or deployed)
- A frontend framework set up (React, Next.js, Vue, etc.)
- Basic understanding of OAuth2/OIDC flows
Overview
Your Auth Portal will handle three main flows:
┌───────────────────────────────────────────────────────────────────┐
│ Auth Portal Responsibilities │
├───────────────────────────────────────────────────────────────────┤
│ │
│ 1. Login Flow │
│ └─ Receive login_challenge → Authenticate user → Accept/Reject│
│ │
│ 2. Consent Flow │
│ └─ Receive consent_challenge → Show scopes → Accept/Reject │
│ │
│ 3. Logout Flow │
│ └─ Receive logout_challenge → Confirm → Accept │
│ │
└───────────────────────────────────────────────────────────────────┘
Configuration
First, configure Shyntr to redirect to your Auth Portal:
# Your Auth Portal URLs
EXTERNAL_LOGIN_URL=http://localhost:3000/login
EXTERNAL_CONSENT_URL=http://localhost:3000/consent
Login Flow Implementation
Step 1: Create the Login Page
import { useEffect, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
interface LoginChallenge {
challenge: string;
client: {
client_id: string;
client_name: string;
logo_uri?: string;
};
request_url: string;
skip: boolean;
subject?: string;
}
export default function LoginPage() {
const searchParams = useSearchParams();
const router = useRouter();
const [challenge, setChallenge] = useState<LoginChallenge | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const loginChallenge = searchParams.get('login_challenge');
const ADMIN_API = process.env.NEXT_PUBLIC_SHYNTR_ADMIN_URL;
useEffect(() => {
if (!loginChallenge) {
setError('Missing login challenge');
setLoading(false);
return;
}
// Fetch challenge details from Shyntr Admin API
fetch(`${ADMIN_API}/admin/login?challenge=${loginChallenge}`)
.then(res => res.json())
.then(data => {
setChallenge(data);
// If skip is true, the user already has a session
if (data.skip) {
acceptLogin(data.subject);
}
setLoading(false);
})
.catch(err => {
setError('Failed to load login challenge');
setLoading(false);
});
}, [loginChallenge]);
const acceptLogin = async (subject: string, remember = false) => {
const response = await fetch(`${ADMIN_API}/admin/login/accept`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: loginChallenge,
subject,
remember,
remember_for: 3600, // 1 hour
context: {
// Add custom claims here
}
})
});
const { redirect_to } = await response.json();
window.location.href = redirect_to;
};
const rejectLogin = async () => {
const response = await fetch(`${ADMIN_API}/admin/login/reject`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: loginChallenge,
error: 'access_denied',
error_description: 'User denied the login request'
})
});
const { redirect_to } = await response.json();
window.location.href = redirect_to;
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const remember = formData.get('remember') === 'on';
try {
// Validate credentials against your user system
const userResponse = await fetch('/api/auth/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!userResponse.ok) {
setError('Invalid credentials');
return;
}
const user = await userResponse.json();
// Accept the login with the user's ID
await acceptLogin(user.id, remember);
} catch (err) {
setError('Authentication failed');
}
};
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div className="login-container">
<div className="login-card">
{challenge?.client.logo_uri && (
<img
src={challenge.client.logo_uri}
alt={challenge.client.client_name}
className="client-logo"
/>
)}
<h1>Sign in to {challenge?.client.client_name}</h1>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
name="email"
required
autoComplete="email"
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
type="password"
id="password"
name="password"
required
autoComplete="current-password"
/>
</div>
<div className="form-group checkbox">
<input type="checkbox" id="remember" name="remember" />
<label htmlFor="remember">Remember me</label>
</div>
<button type="submit" className="btn-primary">
Sign In
</button>
<button type="button" onClick={rejectLogin} className="btn-secondary">
Cancel
</button>
</form>
</div>
</div>
);
}
When skip: true is returned in the challenge response, the user already has a valid session. You can automatically accept the login without showing the form.
Consent Flow Implementation
Step 2: Create the Consent Page
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
interface ConsentChallenge {
challenge: string;
client: {
client_id: string;
client_name: string;
logo_uri?: string;
policy_uri?: string;
tos_uri?: string;
};
requested_scope: string[];
skip: boolean;
subject: string;
}
const SCOPE_DESCRIPTIONS: Record<string, string> = {
openid: 'Verify your identity',
profile: 'Access your basic profile information (name, picture)',
email: 'Access your email address',
offline_access: 'Maintain access when you are not present',
};
export default function ConsentPage() {
const searchParams = useSearchParams();
const [challenge, setChallenge] = useState<ConsentChallenge | null>(null);
const [selectedScopes, setSelectedScopes] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const consentChallenge = searchParams.get('consent_challenge');
const ADMIN_API = process.env.NEXT_PUBLIC_SHYNTR_ADMIN_URL;
useEffect(() => {
if (!consentChallenge) return;
fetch(`${ADMIN_API}/admin/consent?challenge=${consentChallenge}`)
.then(res => res.json())
.then(data => {
setChallenge(data);
setSelectedScopes(data.requested_scope);
// If skip is true, consent was already granted
if (data.skip) {
acceptConsent(data.requested_scope);
}
setLoading(false);
});
}, [consentChallenge]);
const acceptConsent = async (scopes: string[], remember = false) => {
const response = await fetch(`${ADMIN_API}/admin/consent/accept`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: consentChallenge,
grant_scope: scopes,
remember,
remember_for: 3600,
session: {
// Optional: Add custom claims to tokens
id_token: {
custom_claim: 'value'
},
access_token: {
custom_claim: 'value',
amr: ['pwd', 'mfa'] // Authentication Methods Reference
}
}
})
});
const { redirect_to } = await response.json();
window.location.href = redirect_to;
};
const rejectConsent = async () => {
const response = await fetch(`${ADMIN_API}/admin/consent/reject`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: consentChallenge,
error: 'access_denied',
error_description: 'User denied consent'
})
});
const { redirect_to } = await response.json();
window.location.href = redirect_to;
};
const toggleScope = (scope: string) => {
if (scope === 'openid') return; // openid is always required
setSelectedScopes(prev =>
prev.includes(scope)
? prev.filter(s => s !== scope)
: [...prev, scope]
);
};
if (loading) return <div>Loading...</div>;
return (
<div className="consent-container">
<div className="consent-card">
{challenge?.client.logo_uri && (
<img
src={challenge.client.logo_uri}
alt={challenge.client.client_name}
/>
)}
<h1>{challenge?.client.client_name} wants to access your account</h1>
<p className="subject">Logged in as: {challenge?.subject}</p>
<div className="scopes-list">
<h3>This will allow the application to:</h3>
{challenge?.requested_scope.map(scope => (
<div key={scope} className="scope-item">
<input
type="checkbox"
id={scope}
checked={selectedScopes.includes(scope)}
onChange={() => toggleScope(scope)}
disabled={scope === 'openid'}
/>
<label htmlFor={scope}>
<strong>{scope}</strong>
<span>{SCOPE_DESCRIPTIONS[scope] || scope}</span>
</label>
</div>
))}
</div>
<div className="legal-links">
{challenge?.client.policy_uri && (
<a href={challenge.client.policy_uri} target="_blank">
Privacy Policy
</a>
)}
{challenge?.client.tos_uri && (
<a href={challenge.client.tos_uri} target="_blank">
Terms of Service
</a>
)}
</div>
<div className="actions">
<button
onClick={() => acceptConsent(selectedScopes, true)}
className="btn-primary"
>
Allow Access
</button>
<button onClick={rejectConsent} className="btn-secondary">
Deny
</button>
</div>
</div>
</div>
);
}
Access tokens are generated as RFC 9068-compliant JWTs. Your downstream APIs should cryptographically verify the token's signature, validate the iss (Issuer), and check the amr claim to enforce step-up authentication if highly sensitive endpoints require MFA.
Logout Flow Implementation
Step 3: Create the Logout Page
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
export default function LogoutPage() {
const searchParams = useSearchParams();
const [loading, setLoading] = useState(true);
const logoutChallenge = searchParams.get('logout_challenge');
const ADMIN_API = process.env.NEXT_PUBLIC_SHYNTR_ADMIN_URL;
useEffect(() => {
if (!logoutChallenge) return;
// For seamless logout, auto-accept
fetch(`${ADMIN_API}/admin/logout/accept`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenge: logoutChallenge })
})
.then(res => res.json())
.then(data => {
// Clear any local session/cookies
localStorage.removeItem('user_session');
// Redirect to post-logout URL
window.location.href = data.redirect_to;
});
}, [logoutChallenge]);
if (loading) return <div>Signing out...</div>;
return null;
}
Admin API Reference
Login Endpoints
| Endpoint | Method | Description |
|---|---|---|
/admin/login | GET | Fetch login challenge details |
/admin/login/accept | PUT | Accept login and continue flow |
/admin/login/reject | PUT | Reject login request |
Consent Endpoints
| Endpoint | Method | Description |
|---|---|---|
/admin/consent | GET | Fetch consent challenge details |
/admin/consent/accept | PUT | Accept consent with granted scopes |
/admin/consent/reject | PUT | Reject consent request |
Logout Endpoints
| Endpoint | Method | Description |
|---|---|---|
/admin/logout | GET | Fetch logout request details |
/admin/logout/accept | PUT | Accept and complete logout |
Adding Custom Claims
You can inject custom claims into tokens during the consent flow:
await fetch(`${ADMIN_API}/admin/consent/accept`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: consentChallenge,
grant_scope: scopes,
session: {
// Claims added to ID Token
id_token: {
department: 'Engineering',
employee_id: 'EMP-12345',
roles: ['admin', 'developer']
},
// Claims added to Access Token (introspection)
access_token: {
permissions: ['read:users', 'write:users']
}
}
})
});
Only include claims in tokens that the client is authorized to receive. Sensitive data should not be included in tokens returned to untrusted clients.
3. Implementing the Logout UI
When a user initiates a logout from a client application, Shyntr creates a logout_challenge and redirects them to your Auth Portal's /logout route.
- Fetch Logout Details:
const response = await fetch(`${ADMIN_API}/admin/logout?logout_challenge=${challenge}`);
const logoutData = await response.json();
// Displays: "Are you sure you want to log out from {logoutData.client.client_name}?"
- Accept the Logout: Once the user confirms, notify Shyntr to terminate the session:
const response = await fetch(`${ADMIN_API}/admin/logout/accept`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenge })
});
const { redirect_to } = await response.json();
// Redirect the user to the safe post-logout URI
window.location.href = redirect_to;
The challenge parameter (e.g., login_verifier or logout_challenge) acts as a cryptographic nonce. Once you call PUT /admin/login/accept or PUT /admin/logout/accept, Shyntr consumes this state to prevent Replay Attacks. If the user refreshes the page after a successful accept, the challenge will be invalidated. Always ensure you follow the redirect_to URL immediately after a successful response.
Notice that we use window.location.href = redirect_to; instead of making another fetch() call.
The redirect_to URL takes the user back to Shyntr's public API (/saml/resume or /oidc/callback), where Shyntr will set a highly secure, HTTP-Only session cookie on the user's browser. If you try to follow this redirect via AJAX/Fetch, the browser will drop the cookie for security reasons, and the login will silently fail. Always let the browser handle the final redirect.
Error Handling
Always handle errors gracefully:
const rejectWithError = async (
type: 'login' | 'consent',
challenge: string,
error: string,
description: string
) => {
const response = await fetch(`${ADMIN_API}/admin/${type}/reject`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge,
error,
error_description: description,
error_hint: 'Additional debugging information'
})
});
const { redirect_to } = await response.json();
window.location.href = redirect_to;
};
// Usage
await rejectWithError(
'login',
loginChallenge,
'access_denied',
'The user credentials were invalid'
);
Next Steps
- Learn how to Manage Clients & Identity Providers
- Configure Environment Variables for production
- Explore the CLI Reference for automation