Skip to main content
Version: 1.0.0

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.

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

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.

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