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
Password Method
The Password method is available starting from Shyntr v1.1 and later.
Shyntr treats Password Login as a backend-managed password authentication flow. The Auth Portal renders the available method and submits credentials to the Password Login endpoint returned by Shyntr, but the portal is not the identity authority and does not normalize identity.
The Shyntr backend handles the password authentication flow, builds the normalized identity context, persists that context, and continues the login accept flow. Later OIDC and SAML outputs consume the persisted normalized identity context when projecting the authenticated user into protocol-specific responses.
If Shyntr cannot resolve a valid login_url, the Password method is not available. In that case:
passwordis not returned by/hub/auth/methods- the portal must not render a Password option
- the portal must hide Password when
login_urlis missing or unusable
Current Scope
The current implementation is limited to Password Login. Future connector or challenge extensions such as CAPTCHA, passkeys, step-up checks, or additional factors may be added later, but they are not part of the current documented behavior.
Resolution Logic
Shyntr resolves the Password method in this order:
- Tenant-specific active assignment
- Global active assignment
- Otherwise, the Password method is not available
This precedence means a tenant-specific active assignment overrides the global one for that tenant. If neither active assignment produces a valid login_url, Shyntr does not expose the Password method.
LDAP, OIDC, and SAML methods continue to be returned independently of Password Login resolution.
/hub/auth/methods Behavior
The /hub/auth/methods endpoint always returns these method types:
samloidcldap
The endpoint returns password only when Shyntr resolves a valid login_url using the precedence rules above.
Example response when Password is available:
[
{
"type": "password",
"name": "Password",
"login_url": "https://auth.example.com/hub/login/password"
}
]
If no valid login_url exists, password is omitted from the response entirely.
Portal Responsibilities
When the portal receives password from /hub/auth/methods, it should treat the method as backend-provided metadata:
- render the Password option only when
type: "password"is present - post username and password to the returned
login_url - do not hardcode a local Password flow as a fallback
- do not build or persist normalized identity context in the portal
This keeps method availability aligned with backend resolution and tenant scoping.
Dashboard Configuration
The dashboard manages tenant-level Password Login URL configuration. This configuration is scoped to Password Login only: tenant-specific active assignments can provide a tenant-managed password login URL, and a global active assignment can provide the fallback URL.
Do not treat this as a connector catalog or flow builder. The documented configuration controls Password Login endpoint resolution only.
Secure Deployment Requirements
Deploy the Password Login endpoint as a controlled backend integration:
- expose
login_urlonly over HTTPS - point
login_urlto a backend-managed Password Login endpoint - keep credential verification and normalized identity handling outside the portal UI layer
- do not render or attempt Password login when
/hub/auth/methodsdoes not returnpassword - do not log passwords
- do not log raw normalized identity context
- treat the backend-managed Password Login endpoint as a trust boundary
- use controlled network exposure for production deployments
- prefer tenant-specific assignments when a tenant requires a dedicated Password entry point
This model keeps the portal headless while ensuring Password availability is centrally resolved and scoped by backend configuration.
Password Login Flow
The Password Login flow keeps credential validation and identity normalization in the Shyntr backend. The portal renders the option only after method resolution and submits credentials to the backend-managed Password Login endpoint.
Password Method Resolution Flow
The Password method is returned only when Shyntr resolves an active password login endpoint for the current tenant context. Tenant-specific active assignments take precedence over the global fallback.
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