Skip to main content
Version: 1.1

Integrating Headless Login & Consent

This guide walks you through implementing custom Login, Consent, and Logout user interfaces that integrate with Shyntr's headless architecture.

Version

This feature is available starting from Shyntr v1.1.

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

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:

  • password is not returned by /hub/auth/methods
  • the portal must not render a Password option
  • the portal must hide Password when login_url is 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:

  1. Tenant-specific active assignment
  2. Global active assignment
  3. 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:

  • saml
  • oidc
  • ldap

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_url only over HTTPS
  • point login_url to 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/methods does not return password
  • 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

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.

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',
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>
);
}
Zero Trust Resource Servers

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

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

EndpointMethodDescription
/admin/loginGETFetch login challenge details
/admin/login/acceptPUTAccept login and continue flow
/admin/login/rejectPUTReject login request
EndpointMethodDescription
/admin/consentGETFetch consent challenge details
/admin/consent/acceptPUTAccept consent with granted scopes
/admin/consent/rejectPUTReject consent request

Logout Endpoints

EndpointMethodDescription
/admin/logoutGETFetch logout request details
/admin/logout/acceptPUTAccept 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.

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.

  1. 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}?"
  1. 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;
State & Replay Protection

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.

Never AJAX the Redirect URL!

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:

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