Use Sign in with Agentuity when users should authenticate with their Agentuity account or grant scoped access to Agentuity resources. Your app still owns its local session after the callback.
The v3 OAuth helpers are flow helpers, not a full session framework. They build authorization URLs, exchange codes, fetch user info, refresh tokens, and store tokens when you need scoped access. Your framework still creates cookies, sessions, redirects, roles, and app-specific authorization.
npm install @agentuity/server honoimport { Hono } from 'hono';
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
import { buildAuthorizeUrl, exchangeToken, fetchUserInfo } from '@agentuity/server';
import type { OAuthFlowConfig } from '@agentuity/server';
interface AppUser {
readonly id: string;
readonly email: string | null;
readonly name: string | null;
}
const app = new Hono();
const STATE_COOKIE = 'agentuity_oauth_state';
const SESSION_COOKIE = 'app_session';
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}
const oauthConfig: OAuthFlowConfig = {
issuer: requireEnv('OAUTH_ISSUER'),
clientId: requireEnv('OAUTH_CLIENT_ID'),
clientSecret: requireEnv('OAUTH_CLIENT_SECRET'),
scopes: process.env.OAUTH_SCOPES ?? 'openid profile email',
};
function getRedirectUri(requestUrl: string): string {
return `${new URL(requestUrl).origin}/auth/callback`;
}
function readAppUser(value: unknown): AppUser | null {
if (typeof value !== 'object' || value === null) {
return null;
}
if (!('id' in value) || typeof value.id !== 'string') {
return null;
}
if (!('email' in value) || (typeof value.email !== 'string' && value.email !== null)) {
return null;
}
if (!('name' in value) || (typeof value.name !== 'string' && value.name !== null)) {
return null;
}
return {
id: value.id,
email: value.email,
name: value.name,
};
}
function readSessionCookie(value: string): AppUser | null {
try {
return readAppUser(JSON.parse(decodeURIComponent(value)));
} catch {
return null;
}
}
app.get('/auth/login', (c) => {
const state = crypto.randomUUID();
const redirectUri = getRedirectUri(c.req.url);
const loginUrl = new URL(buildAuthorizeUrl(redirectUri, oauthConfig));
loginUrl.searchParams.set('state', state);
setCookie(c, STATE_COOKIE, state, {
path: '/',
httpOnly: true,
secure: new URL(c.req.url).protocol === 'https:',
sameSite: 'Lax',
maxAge: 600,
});
return c.redirect(loginUrl.toString());
});
app.get('/auth/callback', async (c) => {
const code = c.req.query('code');
const returnedState = c.req.query('state');
const storedState = getCookie(c, STATE_COOKIE);
deleteCookie(c, STATE_COOKIE, { path: '/' });
if (!code || !returnedState || !storedState || returnedState !== storedState) {
return c.json({ error: 'Invalid OAuth callback' }, 400);
}
try {
const redirectUri = getRedirectUri(c.req.url);
const token = await exchangeToken(code, redirectUri, oauthConfig);
const profile = await fetchUserInfo(token.access_token, oauthConfig);
const user = {
id: profile.sub,
email: profile.email ?? null,
name: profile.name ?? null,
} satisfies AppUser;
setCookie(c, SESSION_COOKIE, encodeURIComponent(JSON.stringify(user)), {
path: '/',
httpOnly: true,
secure: new URL(c.req.url).protocol === 'https:',
sameSite: 'Lax',
maxAge: 60 * 60 * 24,
});
return c.redirect('/');
} catch {
return c.json({ error: 'OAuth sign-in failed' }, 500);
}
});
app.get('/auth/me', (c) => {
const session = getCookie(c, SESSION_COOKIE);
if (!session) {
return c.json({ authenticated: false });
}
const user = readSessionCookie(session);
if (!user) {
deleteCookie(c, SESSION_COOKIE, { path: '/' });
return c.json({ authenticated: false }, 401);
}
return c.json({
authenticated: true,
user,
});
});
app.post('/auth/logout', (c) => {
deleteCookie(c, SESSION_COOKIE, { path: '/' });
return c.redirect('/');
});
export default app;This example uses Hono because the flow is easy to see in one file. The same boundary applies in Next.js, React Router, Remix, TanStack Start, SvelteKit, Nuxt, Astro, or any framework route: redirect from the server, exchange the code on the server, then create your app session.
The cookie in this example is intentionally small. In an app with roles, teams, or long-lived sessions, store the session server-side or use your framework's session adapter.
Create an OAuth App
Create the OAuth app in the Agentuity Console or with the CLI. For server-backed framework apps, use a confidential client so the client secret stays on the server.
agentuity cloud oidc create \
--name "My App" \
--description "Sign in with Agentuity" \
--homepage-url "https://example.com" \
--type confidential \
--redirect-uris "https://example.com/auth/callback" \
--scopes "openid,profile,email"The client secret is returned when you create the OAuth app or rotate its secret. Store it in your environment variables before leaving that output.
The redirect URI must match the URL your callback route actually serves. Register local and deployed callback URLs separately when both are used.
The examples on this page use confidential clients. Do not put the client secret in browser code.
Configure Environment Variables
The @agentuity/server helpers read OAUTH_* variables by default:
OAUTH_ISSUER=https://auth.agentuity.cloud
OAUTH_CLIENT_ID=your-client-id
OAUTH_CLIENT_SECRET=your-client-secret
OAUTH_SCOPES=openid profile email| Variable | Description |
|---|---|
OAUTH_ISSUER | OIDC issuer base URL. The helpers derive /authorize, /oauth/token, /userinfo, /revoke, and /end_session from this value. |
OAUTH_CLIENT_ID | Client ID from your OAuth app. |
OAUTH_CLIENT_SECRET | Client secret for confidential clients. exchangeToken() requires it. |
OAUTH_SCOPES | Space-separated scopes requested during sign-in. Defaults to openid profile email. |
The OAuthFlowConfig object also accepts a prompt field that is sent as the OIDC prompt parameter:
| Value | Behavior |
|---|---|
login | Forces the provider to show a fresh sign-in screen, even if the user has an active session. |
consent | Forces the provider to show the consent screen, even if the user has already granted the requested scopes. |
none | Requires a silent authentication. Returns an error if the user is not already signed in. |
select_account | Prompts the user to choose an account, useful when users may have multiple accounts. |
Omit prompt for normal sign-in flow. Pass it only when your app needs to force re-authentication or re-consent:
const oauthConfig: OAuthFlowConfig = {
issuer: requireEnv('OAUTH_ISSUER'),
clientId: requireEnv('OAUTH_CLIENT_ID'),
clientSecret: requireEnv('OAUTH_CLIENT_SECRET'),
// force consent screen every time, e.g. during re-authorization
prompt: 'consent',
};Pass explicit endpoint URLs only when you need to override issuer-derived URLs:
| Variable | Default from OAUTH_ISSUER |
|---|---|
OAUTH_AUTHORIZE_URL | {issuer}/authorize |
OAUTH_TOKEN_URL | {issuer}/oauth/token |
OAUTH_USERINFO_URL | {issuer}/userinfo |
OAUTH_REVOKE_URL | {issuer}/revoke |
OAUTH_END_SESSION_URL | {issuer}/end_session |
The public discovery document is available at:
https://auth.agentuity.cloud/.well-known/openid-configurationUse the discovery document when you need endpoint metadata or the current JWKS URI for token verification.
The CLI --scopes flag is comma-separated, for example openid,profile,email. OAUTH_SCOPES is space-separated because it is sent as the OAuth scope parameter.
Store Tokens for Scoped Access
If your app needs to call Agentuity APIs on behalf of the signed-in user, store OAuth tokens server-side. KeyValueTokenStorage stores tokens in Agentuity KV and refreshes expired access tokens when a refresh token and OAuth config are available.
npm install @agentuity/keyvalueimport { KeyValueClient } from '@agentuity/keyvalue';
import { KeyValueTokenStorage, isTokenExpired } from '@agentuity/server';
import type { OAuthFlowConfig, OAuthTokenResponse } from '@agentuity/server';
const kv = new KeyValueClient();
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}
const oauthConfig: OAuthFlowConfig = {
issuer: requireEnv('OAUTH_ISSUER'),
clientId: requireEnv('OAUTH_CLIENT_ID'),
clientSecret: requireEnv('OAUTH_CLIENT_SECRET'),
scopes: process.env.OAUTH_SCOPES ?? 'openid profile email',
};
const tokenStore = new KeyValueTokenStorage(kv, {
config: oauthConfig,
namespace: 'oauth-tokens',
prefix: 'agentuity:',
});
export async function persistTokens(
userId: string,
token: OAuthTokenResponse
): Promise<void> {
await tokenStore.set(userId, token);
}
export async function getUsableAccessToken(userId: string): Promise<string | null> {
const token = await tokenStore.get(userId);
if (!token || isTokenExpired(token)) {
return null;
}
return token.access_token;
}tokenStore.get() attempts refresh before it returns. If refresh fails, it can still return the expired token, so keep the isTokenExpired() check before using access_token.
Request offline_access only when your app needs refresh tokens:
OAUTH_SCOPES=openid profile email offline_accessWhen a user disconnects the app, call tokenStore.invalidate(userId) to remove the stored token and revoke it with the provider when possible.
Token Lifecycle Helpers
tokenStore.invalidate() handles revocation as part of the storage layer. When you need to call the token endpoints directly, the same primitives KeyValueTokenStorage uses internally are also exported.
refreshToken(refreshTokenValue, config?) exchanges a refresh token for a new access token:
import { refreshToken } from '@agentuity/server';
import type { OAuthFlowConfig, OAuthTokenResponse } from '@agentuity/server';
// Uses OAUTH_* env vars when config is omitted
export async function manualRefresh(
currentRefreshToken: string,
config: OAuthFlowConfig
): Promise<OAuthTokenResponse> {
return refreshToken(currentRefreshToken, config);
}refreshToken() does not require clientSecret. Confidential client secrets are included if present in config, but the call succeeds without them because the server decides whether to require the secret.
logout(token, config?) revokes an access or refresh token per RFC 7009:
import { logout } from '@agentuity/server';
import type { OAuthFlowConfig } from '@agentuity/server';
export async function revokeAccess(
token: string,
config: OAuthFlowConfig
): Promise<void> {
// prefer revoking the refresh_token to invalidate all sessions
await logout(token, config);
}Per RFC 7009, the revocation endpoint returns success even if the token was already invalid. Prefer revoking the refresh token over the access token because revoking the refresh token invalidates the entire grant.
Scopes and Consent
Start with openid profile email. Add service scopes only when your app needs them. Users see requested scopes during consent.
To browse available scopes, use the interactive scope picker:
agentuity cloud oidc createIf you already know the scopes, pass them with --scopes:
agentuity cloud oidc create \
--name "My App" \
--homepage-url "https://example.com" \
--type confidential \
--redirect-uris "https://example.com/auth/callback" \
--scopes "openid,profile,email,offline_access"Next Steps
- Choosing Authentication: decide between OIDC and framework-owned sessions
- CLI OAuth Commands: create clients, rotate secrets, and inspect connected users
- REST API OAuth Reference: manage OAuth applications and consent grants over HTTP
- Framework-Owned Sessions: connect browser UI to your framework routes