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:
.env
# 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
pages/login.tsx (Next.js Example)
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>
);
}
Skip Login
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
pages/consent.tsx (Next.js Example)
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'
}
}
})
});
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>
);
}
Logout Flow Implementation
Step 3: Create the Logout Page
pages/logout.tsx (Next.js Example)
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:
Custom Claims Example
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']
}
}
})
});
Security Note
Only include claims in tokens that the client is authorized to receive. Sensitive data should not be included in tokens returned to untrusted clients.
Error Handling
Always handle errors gracefully:
Error Handling Pattern
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