npm package: @ovixa/auth-client
Description: Client SDK for Ovixa Auth service
Latest version: 0.4.2

# @ovixa/auth-client

Client SDK for the Ovixa Auth service. Provides authentication, token verification, and session management for applications using Ovixa's centralized identity provider.

## Features

- Email/password authentication (signup, login, password reset)
- **Passkey (WebAuthn) authentication** - phishing-resistant passwordless sign-in
- OAuth integration (Google, GitHub)
- JWT verification with JWKS caching
- Automatic token refresh
- Framework integrations (Astro, Express)
- TypeScript-first with full type definitions

## Installation

```bash
npm install @ovixa/auth-client
# or
pnpm add @ovixa/auth-client
```

## Quick Start

```typescript
import { OvixaAuth } from '@ovixa/auth-client';

const auth = new OvixaAuth({
  authUrl: 'https://auth.example.com',
  realmId: 'your-realm-id',
});

// Sign up a new user
await auth.signup({
  email: 'user@example.com',
  password: 'SecurePassword123!',
  redirectUri: 'https://yourapp.com/verify-callback',
});

// Log in and get tokens
const tokens = await auth.login({
  email: 'user@example.com',
  password: 'SecurePassword123!',
});

// Convert to AuthResult for easier handling
const result = await auth.toAuthResult(tokens);
console.log('User:', result.user.email);
console.log('Expires:', result.session.expiresAt);

// Verify a JWT token
const verified = await auth.verifyToken(tokens.access_token);
console.log('User ID:', verified.payload.sub);
```

## API Reference

### `OvixaAuth`

Main client class for interacting with the Ovixa Auth service.

#### Constructor

```typescript
new OvixaAuth(config: AuthClientConfig)
```

| Option         | Type     | Required | Description                                 |
| -------------- | -------- | -------- | ------------------------------------------- |
| `authUrl`      | `string` | Yes      | Base URL of the Ovixa Auth service          |
| `realmId`      | `string` | Yes      | The realm ID to authenticate against        |
| `clientSecret` | `string` | No       | Client secret (for server-side use)         |
| `jwksCacheTtl` | `number` | No       | JWKS cache duration in ms (default: 1 hour) |

### Authentication Methods

#### `signup(options)`

Create a new user account. A verification email is sent after signup.

```typescript
await auth.signup({
  email: 'user@example.com',
  password: 'SecurePassword123!',
  redirectUri: 'https://yourapp.com/verify-callback', // Optional
});
```

#### `login(options)`

Authenticate with email and password.

```typescript
const tokens = await auth.login({
  email: 'user@example.com',
  password: 'SecurePassword123!',
});
// Returns: { access_token, refresh_token, token_type, expires_in }
```

#### `logout(refreshToken)`

Revoke a refresh token to log out.

```typescript
await auth.logout(refreshToken);
```

### Email Verification

#### `verifyEmail(options)`

Verify email using a token (returns tokens for automatic login).

```typescript
const tokens = await auth.verifyEmail({
  token: 'verification-token-from-email',
});
```

#### `resendVerification(options)`

Resend the verification email.

```typescript
await auth.resendVerification({
  email: 'user@example.com',
  redirectUri: 'https://yourapp.com/verify-callback', // Optional
});
```

### Password Reset

#### `forgotPassword(options)`

Request a password reset email.

```typescript
await auth.forgotPassword({
  email: 'user@example.com',
  redirectUri: 'https://yourapp.com/reset-password', // Optional
});
```

### Email Branding

Verification and password reset emails automatically display your realm's `display_name` as the brand name. To customize the branding in emails:

1. Set `display_name` when creating your realm
2. Emails will show your brand (e.g., "Linkdrop") instead of "Ovixa"

If no `display_name` is set, emails default to "Ovixa".

#### `resetPassword(options)`

Set a new password using a reset token.

```typescript
await auth.resetPassword({
  token: 'reset-token-from-email',
  password: 'NewSecurePassword123!',
});
```

### Token Management

#### `verifyToken(token)`

Verify an access token and return the decoded payload.

```typescript
const result = await auth.verifyToken(accessToken);
console.log('User ID:', result.payload.sub);
console.log('Email:', result.payload.email);
console.log('Verified:', result.payload.email_verified);
```

#### `refreshToken(refreshToken)`

Exchange a refresh token for new tokens.

```typescript
const newTokens = await auth.refreshToken(currentRefreshToken);
// Store the new tokens - refresh token rotation is used
```

#### `toAuthResult(tokenResponse)`

Convert a token response to a structured `AuthResult` with user and session data.

```typescript
const tokens = await auth.login({ email, password });
const result = await auth.toAuthResult(tokens);

// result.user: { id, email, emailVerified }
// result.session: { accessToken, refreshToken, expiresAt }
// result.isNewUser?: boolean (for OAuth flows)
```

#### `clearJwksCache()`

Invalidate the cached JWKS to force a refresh on next verification.

```typescript
auth.clearJwksCache();
```

### Admin Operations

Admin operations require `clientSecret` to be configured. These operations allow server-side management of users within the realm boundary.

**Important:** Never expose `clientSecret` to client-side code.

#### `admin.deleteUser(options)`

Delete a user from your realm. This permanently deletes the user and all associated data (tokens, OAuth accounts).

```typescript
const auth = new OvixaAuth({
  authUrl: 'https://auth.example.com',
  realmId: 'your-realm-id',
  clientSecret: process.env.OVIXA_CLIENT_SECRET, // Required
});

// Delete a user
await auth.admin.deleteUser({ userId: 'user-id-to-delete' });
```

**Error Handling:**

```typescript
try {
  await auth.admin.deleteUser({ userId });
} catch (error) {
  if (error instanceof OvixaAuthError) {
    if (error.code === 'NOT_FOUND') {
      console.error('User not found');
    } else if (error.code === 'FORBIDDEN') {
      console.error('User does not belong to this realm');
    } else if (error.code === 'REALM_UNAUTHORIZED') {
      console.error('Invalid client secret');
    }
  }
}
```

### Passkeys (WebAuthn)

Passkeys provide phishing-resistant, passwordless authentication using FIDO2/WebAuthn.

#### `webauthn.getRegistrationOptions(options)`

Get options for registering a new passkey. **Requires authentication.**

```typescript
const options = await auth.webauthn.getRegistrationOptions({
  accessToken: 'user-access-token',
});
// Returns PublicKeyCredentialCreationOptions for navigator.credentials.create()
```

#### `webauthn.verifyRegistration(options)`

Verify and store a new passkey registration. **Requires authentication.**

```typescript
const result = await auth.webauthn.verifyRegistration({
  accessToken: 'user-access-token',
  registration: credentialResponse, // From navigator.credentials.create()
  deviceName: 'My MacBook', // Optional friendly name
});
// Returns: { success: true, credential_id: string, device_name?: string }
```

#### `webauthn.getAuthenticationOptions(options)`

Get options for signing in with a passkey. Does not require authentication.

```typescript
const options = await auth.webauthn.getAuthenticationOptions({
  email: 'user@example.com', // Optional - provides allowCredentials hint
});
// Returns PublicKeyCredentialRequestOptions for navigator.credentials.get()
```

#### `webauthn.authenticate(options)`

Verify passkey authentication and receive tokens.

```typescript
const tokens = await auth.webauthn.authenticate({
  authentication: credentialResponse, // From navigator.credentials.get()
});
// Returns: { access_token, refresh_token, token_type, expires_in }
```

#### Complete Passkey Flow Example

```typescript
import { OvixaAuth } from '@ovixa/auth-client';

const auth = new OvixaAuth({
  authUrl: 'https://auth.example.com',
  realmId: 'your-realm-id',
});

// Register a passkey (user must be logged in)
async function registerPasskey(accessToken: string) {
  // 1. Get registration options from server
  const options = await auth.webauthn.getRegistrationOptions({ accessToken });

  // 2. Create credential using browser API
  const credential = await navigator.credentials.create({
    publicKey: options,
  });

  if (!credential) throw new Error('Registration cancelled');

  // 3. Verify with server
  const result = await auth.webauthn.verifyRegistration({
    accessToken,
    registration: credential as PublicKeyCredential,
    deviceName: 'My Device',
  });

  return result;
}

// Sign in with passkey
async function signInWithPasskey(email?: string) {
  // 1. Get authentication options
  const options = await auth.webauthn.getAuthenticationOptions({ email });

  // 2. Get credential using browser API
  const credential = await navigator.credentials.get({
    publicKey: options,
  });

  if (!credential) throw new Error('Authentication cancelled');

  // 3. Verify and get tokens
  const tokens = await auth.webauthn.authenticate({
    authentication: credential as PublicKeyCredential,
  });

  return tokens;
}
```

### OAuth

#### `getOAuthUrl(options)`

Generate an OAuth authorization URL.

```typescript
const googleUrl = auth.getOAuthUrl({
  provider: 'google', // or 'github'
  redirectUri: 'https://yourapp.com/auth/callback',
});

// Redirect user to start OAuth flow
window.location.href = googleUrl;
```

After OAuth completes, the user is redirected to your `redirectUri` with tokens as URL hash parameters:

```typescript
// Handle callback in your app
const hash = new URLSearchParams(window.location.hash.slice(1));
const accessToken = hash.get('access_token');
const refreshToken = hash.get('refresh_token');
```

## Framework Integrations

### Astro Middleware

```typescript
// src/middleware.ts
import { createAstroAuth } from '@ovixa/auth-client/astro';
import { OvixaAuth } from '@ovixa/auth-client';

const auth = new OvixaAuth({
  authUrl: import.meta.env.AUTH_URL,
  realmId: import.meta.env.AUTH_REALM_ID,
});

export const onRequest = createAstroAuth({
  auth,
  publicRoutes: ['/', '/login', '/signup', '/api/public/*'],
  loginRedirect: '/login',
  cookies: {
    secure: import.meta.env.PROD,
  },
});
```

Access auth context in pages:

```astro
---
// src/pages/dashboard.astro
const { user, isAuthenticated } = Astro.locals.auth;

if (!isAuthenticated) {
  return Astro.redirect('/login');
}
---

<h1>Welcome, {user.email}</h1>
```

Set cookies after login:

```typescript
// src/pages/api/login.ts
import { setAstroAuthCookies } from '@ovixa/auth-client/astro';
import type { APIContext } from 'astro';

export async function POST({ request, cookies }: APIContext) {
  const { email, password } = await request.json();
  const tokens = await auth.login({ email, password });

  setAstroAuthCookies({ cookies }, tokens);

  return new Response(JSON.stringify({ success: true }));
}
```

Clear cookies on logout:

```typescript
// src/pages/api/logout.ts
import { clearAstroAuthCookies } from '@ovixa/auth-client/astro';

export async function POST({ cookies, locals }: APIContext) {
  if (locals.auth?.session?.refreshToken) {
    await auth.logout(locals.auth.session.refreshToken);
  }

  clearAstroAuthCookies({ cookies });

  return new Response(JSON.stringify({ success: true }));
}
```

### Express Middleware

```typescript
import express from 'express';
import cookieParser from 'cookie-parser';
import { createExpressAuth, requireAuth } from '@ovixa/auth-client/express';
import { OvixaAuth } from '@ovixa/auth-client';

const auth = new OvixaAuth({
  authUrl: process.env.AUTH_URL!,
  realmId: process.env.AUTH_REALM_ID!,
});

const app = express();
app.use(cookieParser());
app.use(
  createExpressAuth({
    auth,
    publicRoutes: ['/', '/login', '/signup'],
    loginRedirect: '/login',
    cookies: {
      secure: process.env.NODE_ENV === 'production',
    },
  })
);

// Access auth context in routes
app.get('/api/me', (req, res) => {
  if (!req.auth?.isAuthenticated) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  res.json({ user: req.auth.user });
});

// Use requireAuth middleware for protected routes
app.get('/dashboard', requireAuth({ redirect: '/login' }), (req, res) => {
  res.render('dashboard', { user: req.auth.user });
});
```

Set cookies after login:

```typescript
import { setExpressAuthCookies } from '@ovixa/auth-client/express';

app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;
  const tokens = await auth.login({ email, password });

  setExpressAuthCookies(res, req, tokens);

  res.json({ success: true });
});
```

### Custom Framework Integration

Implement the `CookieAdapter` interface to add support for other frameworks:

```typescript
import type {
  CookieAdapter,
  SetCookieOptions,
  DeleteCookieOptions,
} from '@ovixa/auth-client/astro';

class HonoCookieAdapter implements CookieAdapter {
  constructor(private context: HonoContext) {}

  getCookie(name: string): string | undefined {
    return this.context.req.cookie(name);
  }

  setCookie(name: string, value: string, options: SetCookieOptions): void {
    this.context.cookie(name, value, options);
  }

  deleteCookie(name: string, options: DeleteCookieOptions): void {
    this.context.cookie(name, '', { ...options, maxAge: 0 });
  }
}
```

## Middleware Configuration

| Option          | Type        | Default  | Description                              |
| --------------- | ----------- | -------- | ---------------------------------------- |
| `auth`          | `OvixaAuth` | Required | The OvixaAuth client instance            |
| `publicRoutes`  | `string[]`  | `[]`     | Routes that bypass authentication        |
| `loginRedirect` | `string`    | -        | URL to redirect unauthenticated requests |
| `autoRefresh`   | `boolean`   | `true`   | Automatically refresh expired tokens     |
| `cookies`       | `object`    | -        | Cookie configuration (see below)         |

### Cookie Options

| Option               | Type      | Default                 | Description                     |
| -------------------- | --------- | ----------------------- | ------------------------------- |
| `accessTokenCookie`  | `string`  | `'ovixa_access_token'`  | Name of access token cookie     |
| `refreshTokenCookie` | `string`  | `'ovixa_refresh_token'` | Name of refresh token cookie    |
| `path`               | `string`  | `'/'`                   | Cookie path                     |
| `secure`             | `boolean` | `true`                  | Use secure cookies (HTTPS only) |
| `httpOnly`           | `boolean` | `true`                  | HTTP-only cookies               |
| `sameSite`           | `string`  | `'lax'`                 | SameSite policy                 |
| `domain`             | `string`  | -                       | Cookie domain                   |

## Error Handling

All methods throw `OvixaAuthError` on failure:

```typescript
import { OvixaAuth, OvixaAuthError } from '@ovixa/auth-client';

try {
  await auth.login({ email, password });
} catch (error) {
  if (error instanceof OvixaAuthError) {
    console.error('Code:', error.code); // e.g., 'INVALID_CREDENTIALS'
    console.error('Message:', error.message);
    console.error('Status:', error.statusCode); // HTTP status code
  }
}
```

### Error Codes

| Code                      | Description                                           |
| ------------------------- | ----------------------------------------------------- |
| `INVALID_CREDENTIALS`     | Wrong email or password                               |
| `EMAIL_NOT_VERIFIED`      | User must verify email before login                   |
| `PASSWORD_RESET_REQUIRED` | Account flagged for mandatory password reset          |
| `INVALID_TOKEN`           | Token is invalid or malformed                         |
| `TOKEN_EXPIRED`           | Token has expired                                     |
| `INVALID_SIGNATURE`       | Token signature verification failed                   |
| `INVALID_ISSUER`          | Token issuer doesn't match                            |
| `INVALID_AUDIENCE`        | Token audience doesn't match                          |
| `RATE_LIMITED`            | Too many requests                                     |
| `NETWORK_ERROR`           | Failed to reach auth service                          |
| `BAD_REQUEST`             | Invalid request parameters                            |
| `UNAUTHORIZED`            | Authentication required                               |
| `FORBIDDEN`               | Access denied (e.g., user belongs to different realm) |
| `NOT_FOUND`               | Resource not found                                    |
| `SERVER_ERROR`            | Auth service error                                    |
| `REALM_UNAUTHORIZED`      | Invalid or missing realm client secret                |
| `CLIENT_SECRET_REQUIRED`  | Admin operation attempted without clientSecret config |

### Handling `PASSWORD_RESET_REQUIRED`

When an administrator flags an account as compromised, login and token refresh will return a `403` error with code `PASSWORD_RESET_REQUIRED`. The user must complete a password reset before they can log in again.

```typescript
try {
  const tokens = await auth.login({ email, password });
} catch (error) {
  if (error instanceof OvixaAuthError) {
    if (error.code === 'PASSWORD_RESET_REQUIRED') {
      // Redirect user to password reset flow
      await auth.forgotPassword({ email });
      // Show message: "Your account requires a password reset. Check your email."
      return;
    }
    // Handle other errors...
  }
}
```

This also applies to token refresh - if you're using automatic token refresh in middleware, handle this error to redirect users to the password reset flow:

```typescript
try {
  const newTokens = await auth.refreshToken(refreshToken);
} catch (error) {
  if (error instanceof OvixaAuthError && error.code === 'PASSWORD_RESET_REQUIRED') {
    // Clear cookies and redirect to login with a message
    clearAuthCookies();
    redirect('/login?reason=password_reset_required');
  }
}
```

## Types

```typescript
// Token response from auth service
interface TokenResponse {
  access_token: string;
  refresh_token: string;
  token_type: 'Bearer';
  expires_in: number;
  is_new_user?: boolean; // OAuth flows only
}

// User information (extracted from JWT claims)
interface User {
  id: string;
  email: string;
  emailVerified: boolean;
  // Note: createdAt is intentionally omitted. The JWT `iat` claim is when the
  // *token* was issued, not when the user was created. If you need the user's
  // creation date, fetch it from the /me endpoint or include it in custom claims.
}

// Session data
interface Session {
  accessToken: string;
  refreshToken: string;
  expiresAt: Date;
}

// Combined auth result
interface AuthResult {
  user: User;
  session: Session;
  isNewUser?: boolean;
}

// Auth context (available in middleware)
interface AuthContext {
  user: User | null;
  session: Session | null;
  isAuthenticated: boolean;
}
```

## License

MIT