Login Page

Your tenant app must serve a login page at /mcp-login. The hub redirects users here during the OAuth flow. After the user authenticates, your app issues an auth code and redirects back to the hub.

Applies to Tenant Delegated connections only. With Identity Brokering or Token Exchange you don't build a login page.

This is a how-to guide. The Contract Specification is the normative reference for query parameters, the auth code format, and PKCE rules.

Route

GET /mcp-login — must return an HTML page with a login form.

Query parameters

The hub appends these query parameters when redirecting:

ParameterRequiredDescription
callback_urlYesThe hub URL to redirect back to after login
stateYesOpaque string — must be passed back unchanged
code_challengeYesPKCE S256 challenge (43-character base64url string)
code_challenge_methodYesAlways S256
tenant_slugNoTenant identifier for multi-tenant UI customization
login_hintNoEmail address to pre-fill the login form (OAuth2 standard)

Security: callback URL validation

You must validate the callback_url against your allowlist before rendering the form or redirecting. This prevents open-redirect attacks. If the callback host is not in your MCP_CONTRACT_ALLOWED_CALLBACK_HOSTS, show an error page.

Auth code issuance

After successful authentication:

  1. Generate a random auth code (32 bytes, base64url-encoded)
  2. Store it with the code_challenge and user ID, with a short TTL (60 seconds)
  3. Redirect to callback_url with ?state={state}&code={code}

On authentication failure, redirect with ?state={state}&error=access_denied.

PKCE S256 format

The code_challenge is always exactly 43 characters of base64url (unpadded SHA-256 of the verifier). Validate the format before storing.

/^[A-Za-z0-9_-]{43}$/

Optional parameters

The hub may also pass optional parameters to enhance the login experience:

Multi-tenant support (tenant_slug)

When the tenant_slug parameter is provided, you can display tenant context in your login UI to help users understand which organization they're signing into.

Example:

GET /mcp-login?callback_url=...&state=...&code_challenge=...&code_challenge_method=S256&tenant_slug=acme-corp

Implementation suggestion:

tsx
{tenant_slug && (
  <p className="text-sm text-gray-600">
    Signing in to: {tenant_slug}
  </p>
)}

Login hint (login_hint)

The login_hint parameter follows the OAuth2 standard and provides an email address to pre-fill the login form. This improves UX for SSO flows and reduces friction when the user's identity is already known.

Example:

GET /mcp-login?callback_url=...&state=...&code_challenge=...&code_challenge_method=S256&login_hint=user@acme.com

Implementation: Pre-fill the email field with the provided value (shown in code examples below).

Implementation

Login page (GET)

javascript
// app/mcp-login/page.tsx
import { isAllowedCallbackUrl } from "@/lib/mcp-contract/config";

export default async function McpLoginPage({ searchParams }) {
  const params = await searchParams;
  const {
    callback_url = "",
    state = "",
    code_challenge = "",
    code_challenge_method = "",
    tenant_slug = "",
    login_hint = "",
  } = params;

  // Open-redirect defense
  if (!callback_url || !isAllowedCallbackUrl(callback_url)) {
    return (
      <div>
        <h1>Invalid callback URL</h1>
        <p>The callback_url is not allowed for this tenant.</p>
      </div>
    );
  }

  if (code_challenge_method && code_challenge_method !== "S256") {
    return (
      <div>
        <h1>Unsupported Method</h1>
        <p>Only code_challenge_method=S256 is supported.</p>
      </div>
    );
  }

  return (
    <form method="POST" action="/api/mcp/login">
      <input type="hidden" name="callback_url" value={callback_url} />
      <input type="hidden" name="state" value={state} />
      <input type="hidden" name="code_challenge" value={code_challenge} />
      <input type="hidden" name="code_challenge_method"
             value={code_challenge_method} />
      <input type="hidden" name="tenant_slug" value={tenant_slug} />

      <label>Email</label>
      <input name="email" type="email" required defaultValue={login_hint} />

      <label>Password</label>
      <input name="password" type="password" required />

      <button type="submit">Sign In</button>
    </form>
  );
}

Login handler (POST)

javascript
// app/api/mcp/login/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { issueAuthCode } from "@/lib/mcp-contract/auth-code-store";
import { isAllowedCallbackUrl } from "@/lib/mcp-contract/config";

const CODE_CHALLENGE_RE = /^[A-Za-z0-9_-]{43}$/;

export async function POST(request: NextRequest) {
  const formData = await request.formData();

  const email = formData.get("email") as string | null;
  const password = formData.get("password") as string | null;
  const callbackUrl = formData.get("callback_url") as string | null;
  const state = formData.get("state") as string | null;
  const codeChallenge = formData.get("code_challenge") as string | null;

  if (!email || !password || !callbackUrl || !state || !codeChallenge) {
    return NextResponse.json(
      { error: "Missing required fields" }, { status: 400 });
  }

  if (!isAllowedCallbackUrl(callbackUrl)) {
    return NextResponse.json(
      { error: "callback_url host not allowed" }, { status: 400 });
  }

  if (!CODE_CHALLENGE_RE.test(codeChallenge)) {
    return NextResponse.json(
      { error: "Invalid code_challenge" }, { status: 400 });
  }

  let userId: string;
  try {
    const result = await auth.api.signInEmail({
      body: { email, password },
    });
    if (!result?.user) throw new Error("Auth returned no user");
    userId = result.user.id;
  } catch {
    const errorUrl = new URL(callbackUrl);
    errorUrl.searchParams.set("state", state);
    errorUrl.searchParams.set("error", "access_denied");
    return NextResponse.redirect(errorUrl.toString(), { status: 302 });
  }

  const code = issueAuthCode(userId, codeChallenge);
  const successUrl = new URL(callbackUrl);
  successUrl.searchParams.set("state", state);
  successUrl.searchParams.set("code", code);
  return NextResponse.redirect(successUrl.toString(), { status: 302 });
}

Auth code store

javascript
// lib/mcp-contract/auth-code-store.ts
import { randomBytes, createHash } from "crypto";

const MCP_CONTRACT_AUTH_CODE_TTL_SEC = parseInt(
  process.env.MCP_CONTRACT_AUTH_CODE_TTL_SEC ?? "60", 10
);

interface CodeRecord {
  userId: string;
  codeChallenge: string;
  expiresAt: number;
}

const codes = new Map<string, CodeRecord>();

function sweep() {
  const now = Date.now();
  for (const [code, record] of codes) {
    if (record.expiresAt <= now) codes.delete(code);
  }
}

export function issueAuthCode(
  userId: string, codeChallenge: string
): string {
  sweep();
  const code = randomBytes(32).toString("base64url");
  codes.set(code, {
    userId,
    codeChallenge,
    expiresAt: Date.now() + MCP_CONTRACT_AUTH_CODE_TTL_SEC * 1000,
  });
  return code;
}

export function consumeAuthCode(
  code: string, codeVerifier: string
): { userId: string } | null {
  sweep();
  const record = codes.get(code);
  if (!record || record.expiresAt <= Date.now()) {
    codes.delete(code!);
    return null;
  }

  // PKCE S256: BASE64URL(SHA256(verifier)) must equal stored challenge
  const computed = createHash("sha256")
    .update(codeVerifier, "ascii")
    .digest("base64url");

  if (computed !== record.codeChallenge) return null;

  codes.delete(code); // Single-use
  return { userId: record.userId };
}

Login page (GET)

javascript
// app/mcp-login/page.js
import { isAllowedCallbackUrl } from "@/lib/mcp-contract/config";

export default async function McpLoginPage({ searchParams }) {
  const params = await searchParams;
  const {
    callback_url = "",
    state = "",
    code_challenge = "",
    code_challenge_method = "",
    tenant_slug = "",
    login_hint = "",
  } = params;

  if (!callback_url || !isAllowedCallbackUrl(callback_url)) {
    return (
      <div>
        <h1>Invalid callback URL</h1>
        <p>The callback_url is not allowed for this tenant.</p>
      </div>
    );
  }

  if (code_challenge_method && code_challenge_method !== "S256") {
    return (
      <div>
        <h1>Unsupported Method</h1>
        <p>Only code_challenge_method=S256 is supported.</p>
      </div>
    );
  }

  return (
    <form method="POST" action="/api/mcp/login">
      <input type="hidden" name="callback_url" value={callback_url} />
      <input type="hidden" name="state" value={state} />
      <input type="hidden" name="code_challenge" value={code_challenge} />
      <input type="hidden" name="code_challenge_method"
             value={code_challenge_method} />
      <input type="hidden" name="tenant_slug" value={tenant_slug} />

      <label>Email</label>
      <input name="email" type="email" required defaultValue={login_hint} />

      <label>Password</label>
      <input name="password" type="password" required />

      <button type="submit">Sign In</button>
    </form>
  );
}

Login handler (POST)

See POST /api/mcp/login in the Endpoints guide.

Auth code store

javascript
// lib/mcp-contract/auth-code-store.js  (Next.js)
import { randomBytes, createHash } from "crypto";

const MCP_CONTRACT_AUTH_CODE_TTL_SEC = parseInt(
  process.env.MCP_CONTRACT_AUTH_CODE_TTL_SEC ?? "60", 10
);

const codes = new Map();

function sweep() {
  const now = Date.now();
  for (const [code, record] of codes) {
    if (record.expiresAt <= now) codes.delete(code);
  }
}

export function issueAuthCode(userId, codeChallenge) {
  sweep();
  const code = randomBytes(32).toString("base64url");
  codes.set(code, {
    userId,
    codeChallenge,
    expiresAt: Date.now() + MCP_CONTRACT_AUTH_CODE_TTL_SEC * 1000,
  });
  return code;
}

export function consumeAuthCode(code, codeVerifier) {
  sweep();
  const record = codes.get(code);
  if (!record || record.expiresAt <= Date.now()) {
    codes.delete(code);
    return null;
  }

  // PKCE S256: BASE64URL(SHA256(verifier)) must equal stored challenge
  const computed = createHash("sha256")
    .update(codeVerifier, "ascii")
    .digest("base64url");

  if (computed !== record.codeChallenge) return null;

  codes.delete(code); // Single-use
  return { userId: record.userId };
}

Login page (Thymeleaf template)

html
<!-- src/main/resources/templates/mcp-login.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><title>Sign In</title></head>
<body>
  <h1>Sign In</h1>
  <div th:if="${error}" style="color:red" th:text="${error}"></div>

  <form method="POST" action="/api/mcp/login">
    <input type="hidden" name="callback_url"
           th:value="${callback_url}" />
    <input type="hidden" name="state" th:value="${state}" />
    <input type="hidden" name="code_challenge"
           th:value="${code_challenge}" />
    <input type="hidden" name="code_challenge_method"
           th:value="${code_challenge_method}" />
    <input type="hidden" name="tenant_slug"
           th:value="${tenant_slug}" />

    <label>Email</label>
    <input name="email" type="email" required
           th:value="${login_hint}" />

    <label>Password</label>
    <input name="password" type="password" required />

    <button type="submit">Sign In</button>
  </form>
</body>
</html>

Login page controller (GET)

java
// McpLoginPageController.java
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
public class McpLoginPageController {

    private final McpContractProperties props;

    public McpLoginPageController(McpContractProperties props) {
        this.props = props;
    }

    @GetMapping("/mcp-login")
    public String loginPage(
            @RequestParam("callback_url") String callbackUrl,
            @RequestParam("state") String state,
            @RequestParam("code_challenge") String codeChallenge,
            @RequestParam(value = "code_challenge_method",
                          defaultValue = "S256") String method,
            @RequestParam(value = "tenant_slug",
                          required = false) String tenantSlug,
            @RequestParam(value = "login_hint",
                          required = false) String loginHint,
            Model model) {

        if (!props.isAllowedCallbackUrl(callbackUrl)) {
            model.addAttribute("error",
                "Invalid callback URL — not in allowlist.");
            return "mcp-login";
        }

        if (!"S256".equals(method)) {
            model.addAttribute("error",
                "Only code_challenge_method=S256 is supported.");
            return "mcp-login";
        }

        model.addAttribute("callback_url", callbackUrl);
        model.addAttribute("state", state);
        model.addAttribute("code_challenge", codeChallenge);
        model.addAttribute("code_challenge_method", method);
        model.addAttribute("tenant_slug", tenantSlug);
        model.addAttribute("login_hint", loginHint);
        return "mcp-login";
    }
}

Login handler (POST)

java
// McpLoginController.java
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.view.RedirectView;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.regex.Pattern;

@RestController
public class McpLoginController {

    private static final Pattern CODE_CHALLENGE_RE =
        Pattern.compile("^[A-Za-z0-9_-]{43}$");

    private final McpContractProperties props;
    private final AuthService authService;
    private final AuthCodeStore authCodeStore;

    public McpLoginController(McpContractProperties props,
                              AuthService authService,
                              AuthCodeStore authCodeStore) {
        this.props = props;
        this.authService = authService;
        this.authCodeStore = authCodeStore;
    }

    @PostMapping("/api/mcp/login")
    public RedirectView login(
            @RequestParam("email") String email,
            @RequestParam("password") String password,
            @RequestParam("callback_url") String callbackUrl,
            @RequestParam("state") String state,
            @RequestParam("code_challenge") String codeChallenge) {

        if (!props.isAllowedCallbackUrl(callbackUrl)) {
            return errorRedirect(callbackUrl, state, "access_denied");
        }

        if (!CODE_CHALLENGE_RE.matcher(codeChallenge).matches()) {
            return errorRedirect(callbackUrl, state, "access_denied");
        }

        // Authenticate user against your user directory
        String userId;
        try {
            userId = authService.authenticate(email, password);
        } catch (Exception e) {
            return errorRedirect(callbackUrl, state, "access_denied");
        }

        // Issue auth code
        String code = authCodeStore.issue(userId, codeChallenge);

        var redirect = new RedirectView(
            callbackUrl + "?state=" + enc(state) + "&code=" + enc(code));
        redirect.setStatusCode(HttpStatus.FOUND);
        return redirect;
    }

    private RedirectView errorRedirect(String callbackUrl,
                                        String state, String error) {
        var redirect = new RedirectView(
            callbackUrl + "?state=" + enc(state) +
            "&error=" + enc(error));
        redirect.setStatusCode(HttpStatus.FOUND);
        return redirect;
    }

    private static String enc(String s) {
        return URLEncoder.encode(s, StandardCharsets.UTF_8);
    }
}

Auth code store

java
// AuthCodeStore.java
import org.springframework.stereotype.Component;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Component
public class AuthCodeStore {

    private static final SecureRandom RANDOM = new SecureRandom();
    private final int ttlMs;

    // In production, use Redis or a database instead
    private final Map<String, CodeRecord> codes =
        new ConcurrentHashMap<>();

    public AuthCodeStore(McpContractProperties props) {
        this.ttlMs = props.authCodeTtlSec() * 1000;
    }

    public String issue(String userId, String codeChallenge) {
        sweep();
        byte[] bytes = new byte[32];
        RANDOM.nextBytes(bytes);
        String code = Base64.getUrlEncoder()
            .withoutPadding().encodeToString(bytes);
        codes.put(code, new CodeRecord(
            userId, codeChallenge, System.currentTimeMillis() + ttlMs));
        return code;
    }

    public String consume(String code, String codeVerifier) {
        sweep();
        var record = codes.remove(code);
        if (record == null ||
                record.expiresAt <= System.currentTimeMillis()) {
            return null;
        }

        // PKCE S256 verification
        try {
            var digest = MessageDigest.getInstance("SHA-256");
            String computed = Base64.getUrlEncoder().withoutPadding()
                .encodeToString(
                    digest.digest(
                        codeVerifier.getBytes(java.nio.charset.StandardCharsets.US_ASCII)));
            if (!computed.equals(record.codeChallenge)) return null;
        } catch (Exception e) {
            return null;
        }

        return record.userId;
    }

    private void sweep() {
        long now = System.currentTimeMillis();
        codes.entrySet()
            .removeIf(e -> e.getValue().expiresAt <= now);
    }

    private record CodeRecord(
        String userId, String codeChallenge, long expiresAt) {}
}

Login page (GET)

typescript
// routes/mcp-login-page.ts
import { Router, Request, Response } from "express";
import { isAllowedCallbackUrl } from "../lib/mcp-contract/config";

const router = Router();

router.get("/mcp-login", (req: Request, res: Response) => {
  const {
    callback_url = "",
    state = "",
    code_challenge = "",
    code_challenge_method = "S256",
    tenant_slug = "",
    login_hint = "",
  } = req.query as Record<string, string>;

  if (!callback_url || !isAllowedCallbackUrl(callback_url)) {
    return res.render("mcp-login", {
      error: "Invalid callback URL — not in allowlist.",
      callback_url: "", state: "", code_challenge: "",
      code_challenge_method: "", tenant_slug: "", login_hint: "",
    });
  }

  if (code_challenge_method !== "S256") {
    return res.render("mcp-login", {
      error: "Only code_challenge_method=S256 is supported.",
      callback_url, state, code_challenge, code_challenge_method,
      tenant_slug, login_hint,
    });
  }

  return res.render("mcp-login", {
    error: null,
    callback_url, state, code_challenge, code_challenge_method,
    tenant_slug, login_hint,
  });
});

export default router;

Login handler (POST)

See POST /api/mcp/login in the Endpoints guide.

Auth code store

typescript
// lib/mcp-contract/auth-code-store.ts  (Node.js / Express)
import { randomBytes, createHash } from "crypto";
import { MCP_CONTRACT_AUTH_CODE_TTL_SEC } from "./config";

interface CodeRecord {
  userId: string;
  codeChallenge: string;
  expiresAt: number;
}

const codes = new Map<string, CodeRecord>();

function sweep(): void {
  const now = Date.now();
  for (const [code, record] of codes) {
    if (record.expiresAt <= now) codes.delete(code);
  }
}

export function issueAuthCode(userId: string, codeChallenge: string): string {
  sweep();
  const code = randomBytes(32).toString("base64url");
  codes.set(code, {
    userId,
    codeChallenge,
    expiresAt: Date.now() + MCP_CONTRACT_AUTH_CODE_TTL_SEC * 1000,
  });
  return code;
}

export function consumeAuthCode(
  code: string, codeVerifier: string
): { userId: string } | null {
  sweep();
  const record = codes.get(code);
  if (!record || record.expiresAt <= Date.now()) {
    codes.delete(code);
    return null;
  }

  // PKCE S256: BASE64URL(SHA256(verifier)) must equal stored challenge
  const computed = createHash("sha256")
    .update(codeVerifier, "ascii")
    .digest("base64url");

  if (computed !== record.codeChallenge) return null;

  codes.delete(code); // Single-use
  return { userId: record.userId };
}

Login page (GET)

javascript
// routes/mcp-login-page.js
const { Router } = require("express");
const { isAllowedCallbackUrl } = require("../lib/mcp-contract/config");

const router = Router();

router.get("/mcp-login", (req, res) => {
  const {
    callback_url = "",
    state = "",
    code_challenge = "",
    code_challenge_method = "S256",
    tenant_slug = "",
    login_hint = "",
  } = req.query;

  // Open-redirect defense
  if (!callback_url || !isAllowedCallbackUrl(callback_url)) {
    return res.render("mcp-login", {
      error: "Invalid callback URL — not in allowlist.",
      callback_url: "", state: "", code_challenge: "",
      code_challenge_method: "", tenant_slug: "", login_hint: "",
    });
  }

  if (code_challenge_method && code_challenge_method !== "S256") {
    return res.render("mcp-login", {
      error: "Only code_challenge_method=S256 is supported.",
      callback_url, state, code_challenge, code_challenge_method,
      tenant_slug, login_hint,
    });
  }

  return res.render("mcp-login", {
    error: null,
    callback_url, state, code_challenge, code_challenge_method,
    tenant_slug, login_hint,
  });
});

module.exports = router;
html
<!-- views/mcp-login.ejs -->
<!DOCTYPE html>
<html>
<head><title>Sign In</title></head>
<body>
  <h1>Sign In</h1>
  <% if (error) { %><p style="color:red"><%= error %></p><% } %>

  <form method="POST" action="/api/mcp/login">
    <input type="hidden" name="callback_url"          value="<%= callback_url %>">
    <input type="hidden" name="state"                 value="<%= state %>">
    <input type="hidden" name="code_challenge"        value="<%= code_challenge %>">
    <input type="hidden" name="code_challenge_method" value="<%= code_challenge_method %>">
    <input type="hidden" name="tenant_slug"           value="<%= tenant_slug %>">

    <label>Email</label>
    <input name="email" type="email" required value="<%= login_hint %>">

    <label>Password</label>
    <input name="password" type="password" required>

    <button type="submit">Sign In</button>
  </form>
</body>
</html>

Login handler (POST)

See POST /api/mcp/login in the Endpoints guide.

Auth code store

javascript
// lib/mcp-contract/auth-code-store.js
const { randomBytes, createHash } = require("crypto");
const { MCP_CONTRACT_AUTH_CODE_TTL_SEC } = require("./config");

const codes = new Map();

function sweep() {
  const now = Date.now();
  for (const [code, record] of codes) {
    if (record.expiresAt <= now) codes.delete(code);
  }
}

function issueAuthCode(userId, codeChallenge) {
  sweep();
  const code = randomBytes(32).toString("base64url");
  codes.set(code, {
    userId,
    codeChallenge,
    expiresAt: Date.now() + MCP_CONTRACT_AUTH_CODE_TTL_SEC * 1000,
  });
  return code;
}

function consumeAuthCode(code, codeVerifier) {
  sweep();
  const record = codes.get(code);
  if (!record || record.expiresAt <= Date.now()) {
    codes.delete(code);
    return null;
  }

  // PKCE S256: BASE64URL(SHA256(verifier)) must equal stored challenge
  const computed = createHash("sha256")
    .update(codeVerifier, "ascii")
    .digest("base64url");

  if (computed !== record.codeChallenge) return null;

  codes.delete(code); // Single-use
  return { userId: record.userId };
}

module.exports = { issueAuthCode, consumeAuthCode };

Login page (GET)

python
# routers/mcp_login_page.py
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from mcp_contract.config import is_allowed_callback_url

router = APIRouter()
templates = Jinja2Templates(directory="templates")


@router.get("/mcp-login", response_class=HTMLResponse)
async def mcp_login_page(
    request: Request,
    callback_url: str = "",
    state: str = "",
    code_challenge: str = "",
    code_challenge_method: str = "S256",
    tenant_slug: str = "",
    login_hint: str = "",
):
    # Open-redirect defense
    if not callback_url or not is_allowed_callback_url(callback_url):
        return templates.TemplateResponse(
            "mcp_login.html",
            {"request": request, "error": "Invalid callback URL — not in allowlist."},
        )

    if code_challenge_method != "S256":
        return templates.TemplateResponse(
            "mcp_login.html",
            {"request": request,
             "error": "Only code_challenge_method=S256 is supported."},
        )

    return templates.TemplateResponse(
        "mcp_login.html",
        {
            "request": request,
            "callback_url": callback_url,
            "state": state,
            "code_challenge": code_challenge,
            "code_challenge_method": code_challenge_method,
            "tenant_slug": tenant_slug,
            "login_hint": login_hint,
            "error": None,
        },
    )
html
{# templates/mcp_login.html #}
<!DOCTYPE html>
<html>
<head><title>Sign In</title></head>
<body>
  <h1>Sign In</h1>
  {% if error %}<p style="color:red">{{ error }}</p>{% endif %}

  <form method="POST" action="/api/mcp/login">
    <input type="hidden" name="callback_url"          value="{{ callback_url }}">
    <input type="hidden" name="state"                 value="{{ state }}">
    <input type="hidden" name="code_challenge"        value="{{ code_challenge }}">
    <input type="hidden" name="code_challenge_method" value="{{ code_challenge_method }}">
    <input type="hidden" name="tenant_slug"           value="{{ tenant_slug }}">

    <label>Email</label>
    <input name="email" type="email" required value="{{ login_hint }}">

    <label>Password</label>
    <input name="password" type="password" required>

    <button type="submit">Sign In</button>
  </form>
</body>
</html>