Sign in with Agentuity

Add Agentuity account sign-in and scoped access to your app with OAuth 2.0 and OIDC

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 hono
typescriptsrc/index.ts
import { 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 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
VariableDescription
OAUTH_ISSUEROIDC issuer base URL. The helpers derive /authorize, /oauth/token, /userinfo, /revoke, and /end_session from this value.
OAUTH_CLIENT_IDClient ID from your OAuth app.
OAUTH_CLIENT_SECRETClient secret for confidential clients. exchangeToken() requires it.
OAUTH_SCOPESSpace-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:

ValueBehavior
loginForces the provider to show a fresh sign-in screen, even if the user has an active session.
consentForces the provider to show the consent screen, even if the user has already granted the requested scopes.
noneRequires a silent authentication. Returns an error if the user is not already signed in.
select_accountPrompts 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:

VariableDefault 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-configuration

Use the discovery document when you need endpoint metadata or the current JWKS URI for token verification.

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/keyvalue
import { 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_access

When 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.

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 create

If 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