SSO Integration Guide

# Ryan Identity SSO Integration Guide

**Complete guide for integrating external services with Ryan Identity using redirect-based SSO**

## Table of Contents

1. [Overview](#overview)
2. [Why Redirect-Based SSO?](#why-redirect-based-sso)
3. [Quick Start](#quick-start)
4. [Complete Integration Flow](#complete-integration-flow)
5. [Implementation Guide](#implementation-guide)
6. [Password Reset Integration](#password-reset-integration)
7. [API Reference](#api-reference)
8. [Security Best Practices](#security-best-practices)
9. [Testing](#testing)
10. [Troubleshooting](#troubleshooting)

## Overview

Ryan Identity is a centralized Single Sign-On (SSO) and user management system for the SaaS Ryan ecosystem. External services integrate with Ryan Identity so users can authenticate once and access all services.

### Key Principles

1. **Authentication happens on Ryan Identity** - Users always enter their password on `identity.ryan.com`, never on your service
2. **Redirect-based flow** - Your service redirects users to Ryan Identity for authentication
3. **Authorization code exchange** - Your service receives a code and exchanges it for user information
4. **Session management on your service** - After authentication, your service creates its own session

## Why Redirect-Based SSO?

**Security Benefits:**
- ✅ User passwords never touch your service
- ✅ Passwords are only entered on Ryan Identity's domain
- ✅ Browser password managers work consistently
- ✅ Reduces attack surface for password theft

**User Experience Benefits:**
- ✅ Users see familiar Ryan Identity branding
- ✅ Consistent login experience across all SaaS Ryan services
- ✅ Browser remembers passwords for `identity.ryan.com`
- ✅ Easy password reset flow

**Developer Benefits:**
- ✅ No password storage or hashing required
- ✅ No password reset logic needed
- ✅ Simplified security compliance
- ✅ Standard OAuth2-style flow

## Quick Start

### Prerequisites

1. **Create an API key** in the Ryan Identity dashboard (`/dashboard/api-keys`)
2. The API key must have the required scopes (e.g., `user:read`, `user:password-reset`)
3. Pre-register your redirect URI(s) when creating the API key

### ⚠️ Important: API Key = Client ID

**Your API key IS your `client_id` in OAuth requests.**

When you create an API key in Ryan Identity, you'll receive a key like:
```
ryanid_550e8400-e29b-41d4-a716-446655440000_a1b2c3d4e5f6...
```

This entire string serves as:
- Your `client_id` parameter in OAuth authorization URLs
- Your `Bearer` token in API requests

**There is no separate client_id or client_secret** - the API key itself is your credential.

### 5-Minute Integration

```javascript
// Store your API key securely (e.g., environment variable)
const RYAN_IDENTITY_API_KEY = process.env.RYAN_IDENTITY_API_KEY;
// Example: "ryanid_550e8400-e29b-41d4-a716-446655440000_a1b2c3d4..."

// 1. User clicks "Login" on your service
app.get('/login', (req, res) => {
  const authUrl = new URL('https://identity.ryan.com/api/oauth/authorize');

  // IMPORTANT: Use your full API key as the client_id
  authUrl.searchParams.set('client_id', RYAN_IDENTITY_API_KEY);
  authUrl.searchParams.set('redirect_uri', 'https://ryaninvoice.com/auth/callback');
  authUrl.searchParams.set('state', generateRandomState()); // CSRF protection
  authUrl.searchParams.set('scope', 'profile');

  // Save state in session for validation
  req.session.oauthState = authUrl.searchParams.get('state');

  res.redirect(authUrl.toString());
});

// 2. Handle callback from Ryan Identity
app.get('/auth/callback', async (req, res) => {
  const { code, state } = req.query;

  // Validate state to prevent CSRF
  if (state !== req.session.oauthState) {
    return res.status(400).send('Invalid state');
  }

  // Exchange code for user info
  const response = await fetch('https://identity.ryan.com/api/oauth/token', {
    method: 'POST',
    headers: {
      // Use your API key as Bearer token
      'Authorization': `Bearer ${RYAN_IDENTITY_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      grant_type: 'authorization_code',
      code: code,
      redirect_uri: 'https://ryaninvoice.com/auth/callback',
    })
  });

  const data = await response.json();

  // Store JWT tokens securely (httpOnly cookies recommended)
  res.cookie('access_token', data.access_token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 86400000 // 24 hours
  });

  res.cookie('refresh_token', data.refresh_token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 2592000000 // 30 days
  });

  res.redirect('/dashboard');
});
```

That's it! Users can now log in through Ryan Identity.

## Complete Integration Flow

### Step-by-Step Flow Diagram

```
┌────────────────────┐
│   User visits      │
│  RyanInvoice.com   │
└──────────┬─────────┘
           │
           ▼
┌────────────────────┐
│  Clicks "Login"    │
└──────────┬─────────┘
           │
           ▼
┌─────────────────────────────────────────────────────────┐
│ RyanInvoice redirects to:                               │
│ identity.ryan.com/api/oauth/authorize?                  │
│   client_id=ryaninvoice&                                │
│   redirect_uri=https://ryaninvoice.com/auth/callback&   │
│   state=random123&                                      │
│   scope=profile                                         │
└──────────┬──────────────────────────────────────────────┘
           │
           ▼
┌────────────────────┐
│  Ryan Identity     │
│  checks if user    │
│  logged in         │
└──────────┬─────────┘
           │
  ┌────────┴────────┐
  │                 │
  ▼                 ▼
NOT LOGGED IN    LOGGED IN
  │                 │
  ▼                 │
┌────────────────┐  │
│ Shows login    │  │
│ page on        │  │
│ identity.ryan  │  │
│ .com           │  │
└────┬───────────┘  │
     │              │
     ▼              │
┌────────────────┐  │
│ User enters    │  │
│ email/password │  │
│ on Ryan        │  │
│ Identity       │  │
└────┬───────────┘  │
     │              │
     └──────┬───────┘
            │
            ▼
┌─────────────────────────────────────┐
│ Ryan Identity generates             │
│ authorization code                  │
└────────┬────────────────────────────┘
         │
         ▼
┌─────────────────────────────────────────────────────┐
│ Ryan Identity redirects back to:                    │
│ ryaninvoice.com/auth/callback?                      │
│   code=abc123xyz&                                   │
│   state=random123                                   │
└────────┬────────────────────────────────────────────┘
         │
         ▼
┌─────────────────────┐
│ RyanInvoice         │
│ validates state     │
│ (CSRF protection)   │
└────────┬────────────┘
         │
         ▼
┌──────────────────────────────────────────────────┐
│ RyanInvoice calls:                               │
│ POST identity.ryan.com/api/oauth/token           │
│ Headers: X-API-Key: ryanid_key...                │
│ Body: {                                          │
│   grant_type: "authorization_code",              │
│   code: "abc123xyz",                             │
│   redirect_uri: "https://ryaninvoice.com/...",  │
│   client_id: "ryaninvoice"                       │
│ }                                                │
└────────┬─────────────────────────────────────────┘
         │
         ▼
┌─────────────────────┐
│ Ryan Identity       │
│ validates code and  │
│ returns user info   │
└────────┬────────────┘
         │
         ▼
┌─────────────────────┐
│ RyanInvoice creates │
│ session with user   │
│ information         │
└────────┬────────────┘
         │
         ▼
┌─────────────────────┐
│ User redirected to  │
│ RyanInvoice         │
│ dashboard           │
│ ✅ LOGGED IN        │
└─────────────────────┘
```

### Key Points

1. **User password stays on Ryan Identity** - Never enters your service
2. **Authorization code is single-use** - Can only be exchanged once
3. **State parameter prevents CSRF** - Must match what you sent
4. **API key required** - To exchange code for user info

## Implementation Guide

### Step 1: Configure Your Service

Create environment variables:

```bash
# .env
RYAN_IDENTITY_URL=https://identity.ryan.com
RYAN_IDENTITY_API_KEY=ryanid_your_key_here
RYAN_IDENTITY_CLIENT_ID=ryaninvoice
APP_URL=https://ryaninvoice.com
```

### Step 2: Create Login Route

When user clicks "Login", redirect them to Ryan Identity:

```typescript
// Next.js App Router example
// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import crypto from 'crypto';

export async function GET(request: NextRequest) {
  // Generate random state for CSRF protection
  const state = crypto.randomBytes(32).toString('hex');

  // Store state in cookie for validation later
  cookies().set('oauth_state', state, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 600, // 10 minutes
  });

  // Build authorization URL
  const authUrl = new URL(`${process.env.RYAN_IDENTITY_URL}/api/oauth/authorize`);
  authUrl.searchParams.set('client_id', process.env.RYAN_IDENTITY_CLIENT_ID!);
  authUrl.searchParams.set('redirect_uri', `${process.env.APP_URL}/auth/callback`);
  authUrl.searchParams.set('state', state);
  authUrl.searchParams.set('scope', 'profile');

  return NextResponse.redirect(authUrl.toString());
}
```

### Step 3: Create Callback Route

Handle the redirect back from Ryan Identity:

```typescript
// app/api/auth/callback/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const code = searchParams.get('code');
  const state = searchParams.get('state');

  // Validate state (CSRF protection)
  const storedState = cookies().get('oauth_state')?.value;

  if (!state || state !== storedState) {
    return NextResponse.redirect(new URL('/login?error=invalid_state', request.url));
  }

  if (!code) {
    return NextResponse.redirect(new URL('/login?error=no_code', request.url));
  }

  try {
    // Exchange authorization code for user info
    const tokenResponse = await fetch(
      `${process.env.RYAN_IDENTITY_URL}/api/oauth/token`,
      {
        method: 'POST',
        headers: {
          'X-API-Key': process.env.RYAN_IDENTITY_API_KEY!,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          grant_type: 'authorization_code',
          code: code,
          redirect_uri: `${process.env.APP_URL}/auth/callback`,
          client_id: process.env.RYAN_IDENTITY_CLIENT_ID!,
        })
      }
    );

    if (!tokenResponse.ok) {
      const error = await tokenResponse.json();
      console.error('Token exchange failed:', error);
      return NextResponse.redirect(new URL('/login?error=token_exchange_failed', request.url));
    }

    const data = await tokenResponse.json();
    const user = data.user;

    // Create session in your application
    // (Implementation depends on your session management)
    await createUserSession(user);

    // Clean up oauth state cookie
    cookies().delete('oauth_state');

    // Redirect to dashboard
    return NextResponse.redirect(new URL('/dashboard', request.url));

  } catch (error) {
    console.error('Authentication error:', error);
    return NextResponse.redirect(new URL('/login?error=auth_failed', request.url));
  }
}

async function createUserSession(user: any) {
  // Your session creation logic here
  // Example with cookies:
  cookies().set('user_id', user.id, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7, // 7 days
  });

  cookies().set('user_email', user.email, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7,
  });
}
```

### Step 4: Add Login Button to Your UI

```tsx
// components/LoginButton.tsx
export function LoginButton() {
  return (
    <a
      href="/api/auth/login"
      className="btn btn-primary"
    >
      Sign in with Ryan Identity
    </a>
  );
}
```

### Step 5: Add Logout

```typescript
// app/api/auth/logout/route.ts
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';

export async function GET() {
  // Clear your session cookies
  cookies().delete('user_id');
  cookies().delete('user_email');

  // Optionally redirect to Ryan Identity logout
  // return NextResponse.redirect(`${process.env.RYAN_IDENTITY_URL}/logout`);

  // Or just redirect to your home page
  return NextResponse.redirect('/');
}
```

## Password Reset Integration

When users forget their password, redirect them to Ryan Identity:

```typescript
// components/ForgotPasswordLink.tsx
export function ForgotPasswordLink() {
  const handleForgotPassword = () => {
    const resetUrl = new URL(`${process.env.NEXT_PUBLIC_RYAN_IDENTITY_URL}/forgot-password`);
    resetUrl.searchParams.set('client_id', process.env.NEXT_PUBLIC_RYAN_IDENTITY_CLIENT_ID!);
    resetUrl.searchParams.set('redirect_uri', `${process.env.NEXT_PUBLIC_APP_URL}/login`);

    window.location.href = resetUrl.toString();
  };

  return (
    <button onClick={handleForgotPassword} className="link">
      Forgot your password?
    </button>
  );
}
```

Users will:
1. Click "Forgot Password" on your service
2. Be redirected to Ryan Identity forgot password page
3. Enter their email on Ryan Identity
4. Receive password reset email
5. Reset their password on Ryan Identity
6. Be redirected back to your service login page

**No password reset logic needed in your service!**

## API Reference

### GET /api/oauth/authorize

Initiates the OAuth2 authorization flow.

**Query Parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| client_id | string | Yes | Your service identifier (e.g., "ryaninvoice") |
| redirect_uri | string | Yes | Where to redirect after authorization |
| state | string | Recommended | Random string for CSRF protection |
| scope | string | No | Requested scopes (default: "profile") |

**Example:**
```
GET https://identity.ryan.com/api/oauth/authorize?client_id=ryaninvoice&redirect_uri=https://ryaninvoice.com/auth/callback&state=abc123
```

**Response:**
- If user is not logged in: Redirects to login page
- If user is logged in: Redirects to `redirect_uri` with `code` and `state` parameters

**Success Redirect:**
```
https://ryaninvoice.com/auth/callback?code=authorization_code_here&state=abc123
```

**Error Redirect:**
```
https://ryaninvoice.com/auth/callback?error=access_denied&error_description=User+denied+access
```

### POST /api/oauth/token

Exchanges authorization code for JWT access and refresh tokens.

**Headers:**
```
X-API-Key: ryanid_your_key_here
Content-Type: application/json
```

**Request Body:**
```json
{
  "grant_type": "authorization_code",
  "code": "authorization_code_from_callback",
  "redirect_uri": "https://your-app.com/auth/callback",
  "client_id": "your_client_id"
}
```

**Success Response (200 OK):**
```json
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "550e8400-e29b-41d4-a716-446655440000",
  "token_type": "Bearer",
  "expires_in": 86400,
  "user": {
    "id": "user-uuid",
    "email": "user@example.com",
    "name": "John Doe",
    "role": "user",
    "orgId": "org-uuid",
    "orgName": "Acme Corp",
    "emailVerified": true,
    "enabled": true
  }
}
```

**Error Responses:**

| Status | Error | Description |
|--------|-------|-------------|
| 400 | Invalid authorization code | Code doesn't exist or is invalid |
| 400 | Authorization code already used | Code can only be used once |
| 400 | Authorization code expired | Code expired (10 minute lifetime) |
| 400 | client_id mismatch | client_id doesn't match the one used to generate code |
| 400 | redirect_uri mismatch | redirect_uri doesn't match the one used to generate code |
| 401 | Missing API key | X-API-Key header missing |
| 401 | Invalid API key | API key is invalid or expired |
| 403 | User does not belong to your organization | User is from different organization |

**Benefits of JWT Tokens:**
- 🎯 **Stateless validation**: Validate tokens locally without API calls to Ryan Identity
- 🔒 **Better security**: Tokens are cryptographically signed and verified
- 🚀 **Better performance**: No network round-trip for token validation
- 💡 **Simpler integration**: Standard OAuth2 + JWT flow
- 🔄 **Automatic refresh**: Use refresh tokens to get new access tokens

**Implementation Example:**

```typescript
// Exchange code for JWT tokens
const tokenResponse = await fetch(
  `${process.env.RYAN_IDENTITY_URL}/api/oauth/token`,
  {
    method: 'POST',
    headers: {
      'X-API-Key': process.env.RYAN_IDENTITY_API_KEY!,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      grant_type: 'authorization_code',
      code: code,
      redirect_uri: `${process.env.APP_URL}/auth/callback`,
      client_id: process.env.RYAN_IDENTITY_CLIENT_ID!,
    })
  }
);

const data = await tokenResponse.json();

// Store tokens in httpOnly cookies
cookies().set('access_token', data.access_token, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax',
  maxAge: 86400, // 24 hours
});

cookies().set('refresh_token', data.refresh_token, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax',
  maxAge: 2592000, // 30 days
});
```

### GET /api/oauth/userinfo

Validates a JWT access token and returns current user information. This endpoint follows the OpenID Connect UserInfo specification.

**Headers:**
```
Authorization: Bearer <access_token>
```

**Success Response (200 OK):**
```json
{
  "sub": "user-uuid",
  "email": "user@example.com",
  "name": "John Doe",
  "role": "user",
  "org_id": "org-uuid",
  "org_name": "Acme Corp",
  "email_verified": true,
  "enabled": true
}
```

**Error Responses:**

| Status | Error | Description |
|--------|-------|-------------|
| 401 | Missing or invalid Authorization header | Bearer token not provided |
| 401 | Invalid or expired access token | Token is invalid or has expired |
| 403 | User account is disabled | User's account has been disabled |
| 404 | User not found | User ID from token doesn't exist |

**Example Usage:**

```typescript
// Validate token and get user info
const userInfoResponse = await fetch(
  `${process.env.RYAN_IDENTITY_URL}/api/oauth/userinfo`,
  {
    headers: {
      'Authorization': `Bearer ${accessToken}`,
    }
  }
);

if (userInfoResponse.ok) {
  const userInfo = await userInfoResponse.json();
  console.log('User:', userInfo);
} else {
  // Token invalid or expired - need to refresh
}
```

### POST /api/oauth/refresh

Exchange a refresh token for a new access token when the current one expires.

**Request Body:**
```json
{
  "grant_type": "refresh_token",
  "refresh_token": "550e8400-e29b-41d4-a716-446655440000"
}
```

**Success Response (200 OK):**
```json
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "new-uuid-v4-refresh-token",
  "token_type": "Bearer",
  "expires_in": 86400
}
```

**Error Responses:**

| Status | Error | Description |
|--------|-------|-------------|
| 400 | Invalid request format | Missing or invalid parameters |
| 401 | Invalid refresh token | Token doesn't exist or has been used |
| 401 | Refresh token expired | Token has expired (30 day lifetime) |
| 403 | User account is disabled | User's account has been disabled |

**Important Notes:**
- ✅ Refresh tokens are **single-use** (automatic rotation)
- ✅ Old refresh token is invalidated after successful exchange
- ✅ New refresh token is issued with each refresh
- ✅ Store new tokens securely after each refresh

**Example Usage:**

```typescript
// Refresh expired access token
const refreshResponse = await fetch(
  `${process.env.RYAN_IDENTITY_URL}/api/oauth/refresh`,
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      grant_type: 'refresh_token',
      refresh_token: currentRefreshToken,
    })
  }
);

if (refreshResponse.ok) {
  const data = await refreshResponse.json();

  // Update stored tokens
  cookies().set('access_token', data.access_token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 86400,
  });

  cookies().set('refresh_token', data.refresh_token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 2592000,
  });
} else {
  // Refresh failed - user needs to login again
  redirect('/login');
}
```

## Docker Networking Considerations

**Running Ryan Identity and your app in Docker?** Understanding the difference between browser and server URLs is critical.

### Quick Docker URL Rules:

| Context | Use | Example |
|---------|-----|---------|
| Browser redirects | `http://localhost:PORT` | OAuth authorize, login redirects |
| Server API calls | `http://ryan-identity:3000` | Token exchange, validation |
| OAuth redirect_uri | `http://localhost:YOUR_PORT` | Where browser returns after login |

**👉 See the complete [Docker Networking Guide](./DOCKER_NETWORKING.md) for detailed examples and troubleshooting.**

### Common Docker Mistakes:

❌ **Using Docker service name in browser code:**
```typescript
// WRONG - Browser can't resolve Docker service names
window.location.href = 'http://ryan-identity:3000/api/oauth/authorize'
```

✅ **Use localhost for browser redirects:**
```typescript
// CORRECT - Browser can access localhost
window.location.href = 'http://localhost:30000/api/oauth/authorize'
```

❌ **Using localhost in server-to-server API calls (less efficient):**
```typescript
// WORKS but not optimal
await fetch('http://localhost:30000/api/oauth/token')
```

✅ **Use Docker service names for server API calls:**
```typescript
// CORRECT - Direct container-to-container communication
await fetch('http://ryan-identity:3000/api/oauth/token')
```

---

## Integration Best Practices

### 1. Let Users Initiate Login

**✅ DO:**
Always let the user click a link or button to start the login process. This gives users control and makes the authentication flow transparent.

```html
<a href="https://identity.ryan.com/api/oauth/authorize?client_id=...&redirect_uri=...&state=...">
  Log in with Ryan Identity
</a>
```

**❌ DON'T:**
- Auto-redirect users without their knowledge
- Force authentication on page load
- Hide the fact that Ryan Identity is being used

### 2. Use Clear Login UI

Make it obvious where users are logging in:

```html
<!-- Good: Clear branding -->
<button class="login-btn">
  <img src="ryan-identity-logo.svg" alt="Ryan Identity" />
  Sign in with Ryan Identity
</button>

<!-- Also good: Transparent about SSO -->
<button>Sign in to RyanInvoice</button>
<p class="text-sm">Powered by Ryan Identity SSO</p>
```

### 3. Handle Redirects Gracefully

After successful authentication, redirect users to where they intended to go:

```javascript
// Before redirecting to login, save the original destination
const returnTo = request.url;
cookies().set('return_to', returnTo);

// After OAuth callback, redirect to original destination
const returnTo = cookies().get('return_to')?.value || '/dashboard';
redirect(returnTo);
```

### 4. Provide Clear Error Messages

When authentication fails, tell users what went wrong:

```javascript
// Good error handling
if (error === 'access_denied') {
  return 'You need to authorize RyanInvoice to access your Ryan Identity account.';
}
if (error === 'invalid_state') {
  return 'Security validation failed. Please try logging in again.';
}
```

## Security Best Practices

### 1. Always Use State Parameter

```javascript
// Generate cryptographically random state
const state = crypto.randomBytes(32).toString('hex');

// Store it securely (httpOnly cookie recommended)
cookies().set('oauth_state', state, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax'
});

// Validate on callback
if (receivedState !== storedState) {
  throw new Error('CSRF attack detected');
}
```

### 2. Validate Redirect URI

- **Whitelist exact redirect URIs** in Ryan Identity configuration
- **Never** accept arbitrary redirect URIs from user input
- Use HTTPS in production

### 3. Secure API Key Storage

```javascript
// ✅ Good - Environment variable
const apiKey = process.env.RYAN_IDENTITY_API_KEY;

// ❌ Bad - Hardcoded
const apiKey = 'ryanid_key_here';

// ❌ Bad - Client-side
// NEVER expose API key to browser/frontend
```

### 4. Use HTTPS

- All OAuth flows must use HTTPS in production
- HTTP only acceptable for localhost development

### 5. Short-Lived Authorization Codes

- Codes expire in 10 minutes
- Codes can only be used once
- Don't store or cache codes

### 6. Session Security

After receiving user info:
- Create httpOnly cookies for session
- Set appropriate expiration
- Use secure flag in production
- Implement CSRF protection for state-changing operations

## Testing

### Testing Locally

1. **Update hosts file** (optional):
   ```
   127.0.0.1 identity.ryan.local
   127.0.0.1 ryaninvoice.local
   ```

2. **Use localhost redirect URIs**:
   ```
   http://localhost:3001/auth/callback
   ```

3. **Test the flow**:
   ```bash
   # Visit your local service
   open http://localhost:3001

   # Click "Login" - should redirect to Ryan Identity
   # Login on Ryan Identity - should redirect back with code
   # Your service exchanges code for user info
   # You're logged in!
   ```

### Testing Checklist

- [ ] User can login via redirect flow
- [ ] State parameter is validated (try tampering)
- [ ] Authorization code can only be used once
- [ ] Expired codes are rejected
- [ ] Invalid codes are rejected
- [ ] User info is correctly received
- [ ] Session is created in your service
- [ ] User can access protected routes
- [ ] User can logout
- [ ] Password reset redirects to Ryan Identity

## Troubleshooting

### Error: "Invalid state"

**Cause:** State parameter doesn't match

**Solution:**
- Ensure you're storing state in a cookie/session
- Check cookie settings (httpOnly, sameSite, secure)
- Verify state is being retrieved correctly in callback

### Error: "Authorization code expired"

**Cause:** Too much time passed between redirect and token exchange

**Solution:**
- Exchange the code immediately in your callback handler
- Don't store codes for later use
- Codes expire in 10 minutes

### Error: "Authorization code already used"

**Cause:** Trying to exchange same code twice

**Solution:**
- Each code can only be exchanged once
- Don't retry failed exchanges with same code
- Start authorization flow again

### User sees "404" after login

**Cause:** redirect_uri not matching

**Solution:**
- Ensure redirect_uri in authorize call matches exactly what's in token exchange
- Include protocol (https://), domain, and path
- No trailing slashes unless intended

### Error: "Invalid client_id"

**Cause:** Using wrong format for client_id parameter

**Solution:**
- ✅ **Use your full API key as the client_id** (e.g., `ryanid_550e8400-...`)
- ❌ Don't use a short string like `"ryaninvoice"` or `"myapp"`
- The entire API key (starting with `ryanid_`) is your client_id
- Example:
  ```javascript
  // ✅ CORRECT
  const clientId = process.env.RYAN_IDENTITY_API_KEY; // "ryanid_550e8400-..."

  // ❌ WRONG
  const clientId = "ryaninvoice";
  ```

### API key error

**Cause:** Missing or invalid API key

**Solution:**
- Check X-API-Key header is set (or Authorization: Bearer header)
- Verify API key starts with `ryanid_`
- Ensure API key hasn't been revoked
- API key must belong to same organization as user

### Cookie issues / Session not persisting

**Cause:** Cookie domain misconfiguration or middleware issues

**Solution:**

**1. Don't set cookie domain explicitly:**
```javascript
// ✅ CORRECT - Let cookies use default domain
res.cookie('access_token', token, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax',
  maxAge: 86400000
  // No domain property - uses current domain by default
});

// ❌ WRONG - Don't set domain explicitly
res.cookie('access_token', token, {
  domain: 'localhost', // This can cause issues
  // ...
});
```

**2. Check for both camelCase and snake_case in middleware:**

Ryan Identity returns JWT payloads with snake_case (e.g., `user_id`, `org_id`), but your app might use camelCase. Handle both in your middleware:

```typescript
// middleware.ts
import { jwtVerify } from 'jose';

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('access_token')?.value;

  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  try {
    const { payload } = await jwtVerify(
      token,
      new TextEncoder().encode(process.env.JWT_SECRET!)
    );

    // Handle both snake_case (from Ryan Identity) and camelCase (your app)
    const userId = payload.user_id || payload.userId;
    const orgId = payload.org_id || payload.orgId;

    if (!userId) {
      throw new Error('Invalid token: missing user_id');
    }

    // Pass user info to route handlers via headers
    const requestHeaders = new Headers(request.headers);
    requestHeaders.set('x-user-id', userId as string);
    requestHeaders.set('x-org-id', orgId as string);

    return NextResponse.next({
      request: {
        headers: requestHeaders,
      },
    });
  } catch (error) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
}
```

**3. Common cookie pitfalls:**
- ❌ Setting `domain: 'localhost'` - causes cross-port issues
- ❌ Setting `sameSite: 'strict'` - prevents OAuth redirects
- ❌ Missing `httpOnly: true` - security risk
- ✅ Use `sameSite: 'lax'` for OAuth flows
- ✅ Let domain default to current domain

**4. Docker + Vite Proxy environments:**

If you're using Vite's proxy feature in Docker, cookie domain configuration is critical:

```yaml
# docker-compose.yml - Frontend with Vite proxy
services:
  frontend:
    environment:
      VITE_API_URL: http://localhost:5180  # Browser uses localhost
    # Vite dev server proxies /api to backend service
```

```typescript
// vite.config.ts
export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://backend:3000',  // Internal Docker service name
        changeOrigin: true
      }
    }
  }
})
```

**The problem:** Setting `domain: 'localhost'` in cookies causes them to fail when Vite proxies requests to the `backend` hostname.

**The solution:** Don't set domain at all:

```javascript
// ✅ CORRECT - Works with both localhost and Docker service names
res.cookie('access_token', token, {
  httpOnly: true,
  sameSite: 'lax',
  path: '/',
  // No domain - cookie works in all contexts
});

// ❌ WRONG - Breaks with proxy
res.cookie('access_token', token, {
  domain: 'localhost',  // Cookie won't forward through proxy
  // ...
});
```

**Why this matters:**
- Browser requests: `http://localhost:5180` (frontend)
- Vite forwards: `http://backend:3000` (internal Docker network)
- Cookie with `domain: 'localhost'` won't send to `backend` hostname
- Removing domain allows cookie to work in both contexts

## Next Steps

1. ✅ Implement the authorization flow
2. ✅ Test login locally
3. ✅ Test password reset flow
4. Configure production URLs
5. Test in staging environment
6. Deploy to production
7. Monitor authentication logs

## Support

For questions or issues:
- Review this documentation
- Check Ryan Identity logs
- Check your service logs
- Contact Ryan Identity team

## Changelog

- **2025-11-03**: Removed legacy User Data Mode - JWT tokens are now the only supported authentication method for better security
- **2025-11-03**: Added JWT mode support with stateless token validation, `/api/oauth/userinfo`, and `/api/oauth/refresh` endpoints
- **2025-11-02**: Initial SSO integration guide created with redirect-based authentication flow