Skip to main content

Direct Sign-In

Both SDKs implement safe Direct Sign-In primitives: random OAuth state, RFC 7636 S256 PKCE, authorization URL construction, callback state and issuer validation, and a redacted authorization-code exchange grant. Both also implement the complete personal-agent orchestration through DirectSignInClient.

Host services remain mandatory

The orchestrator resolves pap.authorizationStateStore, pap.browserHandoff, pap.signer, and pap.dpopProofProvider from Reactor. The core installs no in-memory fallback. The state store must provide atomic take; the browser handoff must open the URL only for the user; signing and DPoP providers must keep private keys in protected custody.

Pythonโ€‹

import httpx

from personal_agent_protocol import (
DirectSignInClient,
)

async with httpx.AsyncClient() as http:
sign_in = DirectSignInClient(reactor=reactor, http_client=http)
started = await sign_in.begin(
authorization_endpoint="https://auth.example.com/oauth/authorize",
token_endpoint="https://auth.example.com/oauth/token",
issuer="https://auth.example.com",
client_id="https://agent.example/agent.json",
redirect_uri="https://agent.example/oauth/callback",
scopes=("poppy:read",),
tenant_id="application-42",
pairwise_user_id=pairwise_user_id,
session_id=signed_out_session.session_id,
)

# Later, at the registered callback route. Never pass this URL to a model.
result = await sign_in.complete(incoming_callback_url)
print(result.granted_scopes, result.missing_scopes)
# The Account Token is already in `pap.tokenStore`; only its reference is here.
account_reference = result.account_token_reference

begin() stores the redacted pending request under a SHA-256 reference to its state and then invokes the trusted browser handoff. complete() atomically consumes that state before validating the exact redirect and issuer, so a callback cannot be replayed. Expired, unknown, and already-used states produce the same safe error class.

The exchange signs a fresh client assertion and creates a fresh DPoP proof for every attempt. It follows no redirects and retries one use_dpop_nonce challenge. A successful response must be a signed-in DPoP token for the existing Session. Extra scopes are rejected; omitted scopes are returned in missing_scopes so the application can continue with reduced authority or ask the user what to do. If the response includes an Account Token (its refresh_token), complete() moves it into the Reactor pap.tokenStore contribution behind an opaque account_โ€ฆ reference before returning (ยง4.8): the result's token never carries it, and account_token_reference names it. Refresh, narrowing, and revocation take that reference (AccountTokenClient), exactly as after Device and Mediated Sign-In. Denial consumes state and exposes no remote description; recovery starts a new flow.

TypeScriptโ€‹

import {
DirectSignInClient,
} from '@datalayer/personal-agent-protocol';

const signIn = new DirectSignInClient({ reactor, fetch });
const started = await signIn.begin({
authorizationEndpoint: 'https://auth.example.com/oauth/authorize',
tokenEndpoint: 'https://auth.example.com/oauth/token',
issuer: 'https://auth.example.com',
clientId: 'https://agent.example/agent.json',
redirectUri: 'https://agent.example/oauth/callback',
scopes: ['poppy:read'],
tenantId: 'application-42',
pairwiseUserId,
sessionId: signedOutSession.sessionId,
});

// Later, at the registered callback route. Never put this URL in UI/model state.
const result = await signIn.complete({
callbackUrl: incomingCallbackUrl,
signal: request.signal,
});
console.log(started.expiresAt, result.grantedScopes, result.missingScopes);
// The Account Token is already in `pap.tokenStore`; only its reference is here.
const accountReference = result.accountTokenReference;

The TypeScript flow has the same one-use, redirect, issuer, nonce, Session, and scope guarantees as Python and propagates an optional AbortSignal to both exchange attempts. Use it in a trusted backend or desktop host whose Reactor contributions keep assertion signing and authorization state out of browser code. A web Preview should request the flow through that host and receive only safe status.

The request URL, state, verifier, authorization code, client assertion, DPoP proof, Session Token, and Account Token are redacted by default. Reveal them only at the browser handoff or final HTTPS serialization boundary. They must not enter model context, logs, analytics, A2UI state, or callback error text.

Follow the lifecycleโ€‹

The lifecycle has two asynchronous halves separated by user action:

StageHost responsibilitySafe application-visible state
1. BeginCreate random state and S256 PKCE; persist the pending request with an expirywaiting_for_user, requested scopes, expiry
2. HandoffOpen the authorization URL in a user-owned browserBrowser opened
3. CallbackAtomically consume state; verify the exact redirect and issuercallback_accepted or a safe error code
4. ExchangeBind the code to the existing Session with fresh client assertion and DPoP proofGranted and missing scopes
5. ContinueStore credentials behind opaque references and resume the same conversationsigned_in, unchanged Session reference

A use_dpop_nonce response repeats only stage 4. The SDK keeps the original authorization code and PKCE verifier for that retry, but creates a fresh client assertion and fresh proof carrying the company's nonce. It retries at most once. The state was already consumed, so a second callback cannot start another exchange.

Two paired runnable examples exercise these invariants against a deterministic local company endpoint without contacting an identity provider:

Both examples intentionally return booleans and scope outcomes rather than request payloads. Their mock transport is pedagogical; production deployments must use protected key and token stores, durable expiring authorization state, and an HTTPS callback owned by the host.

See upstream Direct Sign-In for the complete normative flow.