npm package: @sftech/nestjs-auth-backend
Description: A standalone, reusable NestJS library for OAuth 2.0 / OIDC authentication with Authorization Code Flow.
Latest version: 1.5.0

# @sftech/nestjs-auth-backend

A standalone, reusable NestJS library for OAuth 2.0 / OIDC authentication with Authorization Code Flow.

This library provides a complete authentication microservice that can be used as a central auth service for your microservices architecture.

## Features

- **OAuth 2.0 / OIDC Authorization Code Flow** with PKCE support
- **Database-backed session management** using TypeORM
- **Session-based authentication** with HTTP-only cookies
- **Token management** (Access Token, Refresh Token, ID Token)
- **Single Sign-On (SSO)** with provider logout support
- **Dynamic post-login redirect URL** with OWASP open-redirect protection (v1.4.0+)
- **Native app deep-link relay** for Capacitor / mobile OAuth flows (v1.5.0+)
- **Clean Architecture** (domain, application, infrastructure, presentation)
- **REST API endpoints** for authentication flows

## Installation

```bash
npm install @sftech/nestjs-auth-backend
```

### Peer Dependencies

```bash
npm install @sftech/nestjs-core @sftech/nestjs-db
```

## Usage

### 1. Configure Module

```typescript
import { Module } from '@nestjs/common';
import { NestjsAuthBackendModule } from '@sftech/nestjs-auth-backend';

@Module({
  imports: [
    NestjsAuthBackendModule.register({
      // Module configuration options
    }),
  ],
})
export class AppModule {}
```

### 2. Configuration

The module uses configuration values from `@sftech/nestjs-core` configuration system. Add the following to your `app.config.json`:

```json
{
  "OAUTH_PROVIDER_URL": "https://your-oauth-provider.com/realms/your-realm",
  "OAUTH_AUTHORIZATION_ENDPOINT": "https://your-oauth-provider.com/realms/your-realm/protocol/openid-connect/auth",
  "OAUTH_TOKEN_ENDPOINT": "https://your-oauth-provider.com/realms/your-realm/protocol/openid-connect/token",
  "OAUTH_USERINFO_ENDPOINT": "https://your-oauth-provider.com/realms/your-realm/protocol/openid-connect/userinfo",
  "OAUTH_END_SESSION_ENDPOINT": "https://your-oauth-provider.com/realms/your-realm/protocol/openid-connect/logout",
  "OAUTH_CLIENT_ID": "your-client-id",
  "OAUTH_CLIENT_SECRET": "your-client-secret",
  "OAUTH_REDIRECT_URL": "http://localhost:3000/api/auth/callback",
  "OAUTH_FRONTEND_CALLBACK_URL": "http://localhost:4200/auth/callback",
  "OAUTH_SCOPE": "openid profile email roles",
  "OAUTH_SESSION_MODE": "single",
  "OAUTH_SESSION_MAX_AGE": "3600000",
  "OAUTH_ALLOWED_REDIRECT_ORIGINS": "https://localhost,http://localhost:4200,https://myapp.example.com",
  "OAUTH_NATIVE_APP_SCHEME": "com.example.myapp"
}
```

#### Configuration Parameters

| Parameter | Required | Description | Example |
|-----------|----------|-------------|---------|
| `OAUTH_PROVIDER_URL` | Yes | Base URL of OAuth provider | `https://keycloak.example.com/realms/myrealm` |
| `OAUTH_AUTHORIZATION_ENDPOINT` | Yes | OAuth authorization endpoint | `https://keycloak.example.com/realms/myrealm/protocol/openid-connect/auth` |
| `OAUTH_TOKEN_ENDPOINT` | Yes | OAuth token endpoint | `https://keycloak.example.com/realms/myrealm/protocol/openid-connect/token` |
| `OAUTH_USERINFO_ENDPOINT` | Yes | OAuth userinfo endpoint | `https://keycloak.example.com/realms/myrealm/protocol/openid-connect/userinfo` |
| `OAUTH_END_SESSION_ENDPOINT` | No | OAuth logout endpoint (for SSO logout) | `https://keycloak.example.com/realms/myrealm/protocol/openid-connect/logout` |
| `OAUTH_CLIENT_ID` | Yes | OAuth client ID | `my-app` |
| `OAUTH_CLIENT_SECRET` | Yes | OAuth client secret | `your-secret-here` |
| `OAUTH_REDIRECT_URL` | Yes | OAuth callback URL (this service's own endpoint) | `http://localhost:3000/api/auth/callback` |
| `OAUTH_FRONTEND_CALLBACK_URL` | No | Default frontend URL to redirect to after login | `http://localhost:4200/auth/callback` |
| `OAUTH_SCOPE` | Yes | OAuth scopes to request | `openid profile email roles` |
| `OAUTH_SESSION_MODE` | No | Session mode: `single` or `multi` | `single` (default) |
| `OAUTH_SESSION_MAX_AGE` | No | Session max age in milliseconds | `3600000` (1 hour, default) |
| `OAUTH_ALLOWED_REDIRECT_ORIGINS` | No | Comma-separated list of origins allowed as post-login redirect targets (v1.4.0+) | `https://localhost,http://localhost:4200` |
| `OAUTH_NATIVE_APP_SCHEME` | No | Custom URL scheme for native app deep-link relay (v1.5.0+) | `com.sftech.ebuilder` |

**Notes:**
- Session cookie name is fixed to `oauth_session` (not configurable).
- When `OAUTH_ALLOWED_REDIRECT_ORIGINS` is not set, the library derives a single allowed origin from `OAUTH_FRONTEND_CALLBACK_URL` automatically.
- When `OAUTH_NATIVE_APP_SCHEME` is not set, `GET /auth/native-relay` returns 404.

### 3. Keycloak Example

For Keycloak, the endpoints follow this pattern:

```json
{
  "OAUTH_PROVIDER_URL": "https://your-keycloak.com/realms/your-realm",
  "OAUTH_AUTHORIZATION_ENDPOINT": "https://your-keycloak.com/realms/your-realm/protocol/openid-connect/auth",
  "OAUTH_TOKEN_ENDPOINT": "https://your-keycloak.com/realms/your-realm/protocol/openid-connect/token",
  "OAUTH_USERINFO_ENDPOINT": "https://your-keycloak.com/realms/your-realm/protocol/openid-connect/userinfo",
  "OAUTH_END_SESSION_ENDPOINT": "https://your-keycloak.com/realms/your-realm/protocol/openid-connect/logout"
}
```

## REST API Endpoints

The module provides the following REST endpoints under `/api/auth`:

### `GET /auth/login`

Initiates OAuth login flow by redirecting to the OAuth provider.

**Query Parameters:**

| Parameter | Required | Description |
|-----------|----------|-------------|
| `redirect_url` | No | Post-login redirect URL (see validation rules below) |

**Successful response:** `302 Redirect` to OAuth provider authorization endpoint.

**Error response:** `400 Bad Request` when `redirect_url` is provided but fails validation.

#### redirect_url Validation Rules (v1.4.0+)

When `redirect_url` is supplied the library enforces the following rules in order. Any rule failure returns HTTP 400 with a distinct error message — the request is **never** silently redirected to a fallback.

| Rule | Accepted | Rejected |
|------|----------|---------|
| Empty / whitespace-only | Treated as absent; falls back to `OAUTH_FRONTEND_CALLBACK_URL` | — |
| Relative path starting with `/` (not `//`) | `redirect_url=/auth/callback` | `redirect_url=//evil.com/auth/callback` |
| Absolute URL — must be parseable | `https://localhost/auth/callback` | `not-a-url` |
| Absolute URL — origin must be in the allowlist | Origin is in `OAUTH_ALLOWED_REDIRECT_ORIGINS` | `https://evil.com/auth/callback` |
| Absolute URL — path must be exactly `/auth/callback` | `https://localhost/auth/callback` | `https://localhost/dashboard` |
| Absolute URL — must contain no query parameters | `https://localhost/auth/callback` | `https://localhost/auth/callback?foo=bar` |

**400 error messages:**

| Condition | Message |
|-----------|---------|
| Not a valid URL format | `Invalid redirect URL: not a valid URL format` |
| Origin not in allowlist | `Invalid redirect URL: origin not in allowlist` |
| Path is not `/auth/callback` | `Invalid redirect URL: path must be /auth/callback` |
| Query parameters present | `Invalid redirect URL: must not contain query parameters` |

**Examples:**

```
# Valid — accepted, user is redirected here after login
GET /auth/login?redirect_url=https%3A%2F%2Flocalhost%2Fauth%2Fcallback

# Valid — Capacitor mobile (https://localhost)
GET /auth/login?redirect_url=https%3A%2F%2Flocalhost%2Fauth%2Fcallback

# Valid — Angular dev server
GET /auth/login?redirect_url=http%3A%2F%2Flocalhost%3A4200%2Fauth%2Fcallback

# Valid — production SPA
GET /auth/login?redirect_url=https%3A%2F%2Fmyapp.example.com%2Fauth%2Fcallback

# Valid — native app relay (relative path, always accepted)
GET /auth/login?redirect_url=%2Fauth%2Fnative-relay

# Invalid — origin not in allowlist → 400
GET /auth/login?redirect_url=https%3A%2F%2Fevil.com%2Fauth%2Fcallback

# Invalid — wrong path → 400
GET /auth/login?redirect_url=https%3A%2F%2Flocalhost%2Fdashboard

# No redirect_url — falls back to OAUTH_FRONTEND_CALLBACK_URL (backward compatible)
GET /auth/login
```

---

### `GET /auth/callback`

Handles OAuth callback after user authentication. Exchanges authorization code for tokens and creates a session.

**Query Parameters:**
- `code` (required): Authorization code from OAuth provider
- `state` (required): State parameter for CSRF validation

**Response:** `302 Redirect`

- On success: redirects to the `redirect_url` that was stored in the PKCE challenge (or `OAUTH_FRONTEND_CALLBACK_URL` if none was provided during login).
- On error: redirects to the same target URL with `?error=<code>` appended (see Dynamic Error Redirect below).

---

### `GET /auth/native-relay` (v1.5.0+)

Relay endpoint for native mobile apps (Capacitor, React Native, etc.) completing an OAuth login flow. The native app's in-app browser is redirected here after `GET /auth/callback` sets the session cookie, and this endpoint performs a final redirect to the app's custom URL scheme so the native runtime can capture the session.

**Query Parameters:** None.

**Cookie:** Reads the `oauth_session` HTTP-only cookie set by `GET /auth/callback`.

**Responses:**

| Condition | Status | Location |
|-----------|--------|----------|
| `OAUTH_NATIVE_APP_SCHEME` configured, `oauth_session` cookie present | `302` | `{scheme}://auth/callback?session={sessionId}` |
| `OAUTH_NATIVE_APP_SCHEME` configured, no `oauth_session` cookie | `302` | `{scheme}://auth/callback?error=no_session` |
| `OAUTH_NATIVE_APP_SCHEME` not configured | `404` | — |

**Examples:**

```
# Session cookie present — native app receives the session ID
GET /auth/native-relay
Cookie: oauth_session=abc123
→ 302 com.sftech.ebuilder://auth/callback?session=abc123

# No session cookie — native app receives an error signal
GET /auth/native-relay
→ 302 com.sftech.ebuilder://auth/callback?error=no_session

# OAUTH_NATIVE_APP_SCHEME not configured
GET /auth/native-relay
→ 404 Not Found
```

**Notes:**
- The endpoint does **not** validate whether `sessionId` corresponds to a live session. Session validity is the responsibility of the native app after it receives the deep link.
- The `sessionId` value is URL-encoded before being appended to the redirect URL.
- Use `redirect_url=/auth/native-relay` when calling `GET /auth/login` from a native app (relative paths are always accepted by `validateRedirectUrl`).

---

### `GET /auth/status`

Returns current authentication status. Does not require authentication.

**Response:**
```json
{
  "authenticated": true,
  "userId": "user-id",
  "email": "user@example.com",
  "name": "John Doe",
  "roles": ["admin", "user"],
  "expiresAt": "2024-12-31T23:59:59.000Z",
  "timeRemaining": 3600000
}
```

---

### `GET /auth/user`

Returns current user information from session. Requires authentication.

**Response:**
```json
{
  "sub": "user-id",
  "email": "user@example.com",
  "name": "John Doe",
  "roles": ["admin", "user"],
  "expiresAt": "2024-12-31T23:59:59.000Z",
  "timeRemaining": 3600000
}
```

---

### `POST /auth/refresh`

Refreshes access token using refresh token. Extends session lifetime. Requires authentication.

**Response:**
```json
{
  "message": "Tokens refreshed successfully",
  "expiresAt": "2024-12-31T23:59:59.000Z",
  "timeRemaining": 3600000
}
```

---

### `POST /auth/logout`

Ends user session by deleting from database and clearing cookie. If `OAUTH_END_SESSION_ENDPOINT` is configured, redirects to OAuth provider logout for SSO logout.

**Response (without provider logout):**
```json
{
  "message": "Logout successful"
}
```

**Response (with provider logout):**
`302 Redirect` to OAuth provider logout endpoint

---

### `GET /auth/logout/callback`

Handles OAuth provider logout callback. After the OAuth provider completes logout, they redirect back to this endpoint, which then redirects to the frontend.

**Response:** `302 Redirect` to `OAUTH_FRONTEND_CALLBACK_URL`

## Authentication Flow

### Login Flow (Web)

1. Frontend calls `GET /auth/login` (optionally with `redirect_url` query parameter)
2. Backend validates `redirect_url` if provided (returns 400 on failure — does NOT fall back silently)
3. Backend generates PKCE challenge and stores the validated `redirect_url` alongside it
4. Backend redirects to OAuth provider
5. User authenticates at OAuth provider
6. OAuth provider redirects back to `GET /auth/callback?code=xxx&state=xxx`
7. Backend exchanges code for tokens (with PKCE verifier)
8. Backend retrieves the `redirect_url` stored in the PKCE challenge
9. Backend creates session in database
10. Backend sets `oauth_session` cookie (HTTP-only, SameSite=Lax)
11. Backend redirects browser to the stored `redirect_url` (or `OAUTH_FRONTEND_CALLBACK_URL` if none was stored)
12. Frontend stores cookie and is now authenticated

### Login Flow (Native App — v1.5.0+)

Use this flow when a Capacitor, React Native, or other native app opens an in-app browser to perform OAuth login and needs the session returned via a custom URL scheme deep link.

1. Native app opens an in-app browser pointed at `GET /auth/login?redirect_url=%2Fauth%2Fnative-relay`
2. Backend validates the relative path (always accepted) and stores it in the PKCE challenge
3. Backend redirects to OAuth provider
4. User authenticates at OAuth provider
5. OAuth provider redirects to `GET /auth/callback?code=xxx&state=xxx`
6. Backend exchanges code, creates session, sets `oauth_session` cookie, and redirects to `/auth/native-relay`
7. `GET /auth/native-relay` reads the `oauth_session` cookie and redirects to `{OAUTH_NATIVE_APP_SCHEME}://auth/callback?session={sessionId}`
8. Native app intercepts the deep link, extracts `session` from query parameters, and stores it for subsequent API calls

**Configuration required:**

```json
{
  "OAUTH_NATIVE_APP_SCHEME": "com.sftech.ebuilder"
}
```

**Capacitor example (TypeScript):**

```typescript
import { Browser } from '@capacitor/browser';
import { App } from '@capacitor/app';

// 1. Open in-app browser to start OAuth flow
await Browser.open({
  url: 'https://api.example.com/api/auth/login?redirect_url=%2Fauth%2Fnative-relay',
});

// 2. Listen for the deep link returned by /auth/native-relay
App.addListener('appUrlOpen', async (event) => {
  // event.url === 'com.sftech.ebuilder://auth/callback?session=abc123'
  const url = new URL(event.url);
  const sessionId = url.searchParams.get('session');
  const error = url.searchParams.get('error');

  await Browser.close();

  if (error) {
    console.error('Login failed:', error);
    return;
  }

  if (sessionId) {
    // Store sessionId and use it as the oauth_session cookie value
    // in subsequent API requests (e.g. via Capacitor HTTP plugin or Axios)
    await storage.set('sessionId', sessionId);
    router.push('/home');
  }
});
```

### Logout Flow

1. Frontend calls `POST /auth/logout`
2. Backend deletes session from database
3. Backend clears `oauth_session` cookie
4. **If** `OAUTH_END_SESSION_ENDPOINT` configured:
   - Backend redirects to OAuth provider logout with `id_token_hint`
   - OAuth provider clears SSO session
   - OAuth provider redirects back to `GET /auth/logout/callback` (backend)
   - Backend redirects to `OAUTH_FRONTEND_CALLBACK_URL` (frontend)
5. **Otherwise**: Returns JSON success response

### Session Validation

Other microservices can validate sessions by calling `GET /auth/status` with the session cookie.

See [@sftech/nestjs-auth](../nestjs-auth/README.md) for client library to validate sessions from microservices.

## Dynamic Error Redirect (v1.4.0+)

When OAuth callback processing fails (e.g. invalid authorization code, expired state, provider error), the backend must redirect the browser to an error page rather than returning a JSON error body because the response is a browser redirect flow.

**Behavior:**

1. If the login was initiated with a `redirect_url`, that URL is stored in the PKCE challenge alongside the code verifier.
2. On callback error, the stored `redirect_url` is retrieved from the thrown exception and used as the redirect target.
3. The error code is appended as a `?error=<code>` query parameter.
4. If no `redirect_url` was stored (login was initiated without one), the fallback is `OAUTH_FRONTEND_CALLBACK_URL`.

**Error codes:**

| Code | Condition |
|------|-----------|
| `auth_failed` | Invalid credentials, expired session, unauthorized |
| `invalid_request` | Missing required parameters (code, state) |
| `auth_error` | Unexpected internal error |

**Example redirect on error:**
```
# Login initiated with redirect_url=https://localhost/auth/callback
# Callback fails with invalid code
→ 302 https://localhost/auth/callback?error=auth_failed

# Login initiated without redirect_url
# Callback fails
→ 302 {OAUTH_FRONTEND_CALLBACK_URL}?error=auth_failed
```

**Frontend handling example:**
```typescript
// Angular component at /auth/callback
import { ActivatedRoute } from '@angular/router';

export class AuthCallbackComponent implements OnInit {
  constructor(private route: ActivatedRoute) {}

  ngOnInit(): void {
    const error = this.route.snapshot.queryParamMap.get('error');
    if (error) {
      // Handle error — e.g. show message or redirect to login page
      console.error('Authentication failed:', error);
    }
    // Otherwise: authenticated, proceed to application
  }
}
```

## Open-Redirect Protection (v1.4.0+)

The library enforces OWASP CWE-601 open-redirect protection on all `redirect_url` values before they are stored. Validation happens at the presentation layer in `AuthController.validateRedirectUrl()`.

**Allowlist configuration:**

`OAUTH_ALLOWED_REDIRECT_ORIGINS` accepts a comma-separated list of origins. Each entry can be either a bare origin (`https://example.com`) or a full URL (the origin is extracted automatically).

```json
{
  "OAUTH_ALLOWED_REDIRECT_ORIGINS": "https://localhost,http://localhost:4200,https://myapp.example.com"
}
```

**Fallback when `OAUTH_ALLOWED_REDIRECT_ORIGINS` is not set:**

The library automatically derives one allowed origin from `OAUTH_FRONTEND_CALLBACK_URL`. This preserves backward compatibility for deployments with a single frontend origin.

```json
{
  "OAUTH_FRONTEND_CALLBACK_URL": "http://localhost:4200/auth/callback"
  // Derived allowed origin: http://localhost:4200
}
```

**Multi-origin deployment example (Capacitor + dev + prod):**

```json
{
  "OAUTH_FRONTEND_CALLBACK_URL": "https://myapp.example.com/auth/callback",
  "OAUTH_ALLOWED_REDIRECT_ORIGINS": "https://localhost,http://localhost:4200,https://myapp.example.com"
}
```

This configuration permits all three frontends to pass a `redirect_url`:
- `https://localhost/auth/callback` — Capacitor mobile (Ionic/Capacitor uses `https://localhost`)
- `http://localhost:4200/auth/callback` — Angular development server
- `https://myapp.example.com/auth/callback` — production SPA

**Attack surface covered:**

| Attack | Protection |
|--------|-----------|
| Redirect to unrelated domain (`https://evil.com`) | Origin must be in allowlist |
| Subdomain hijack (`https://localhost.evil.com`) | Exact origin match, not substring |
| Path traversal (`https://localhost/admin`) | Path must be exactly `/auth/callback` |
| Parameter injection (`https://localhost/auth/callback?token=x`) | No query parameters allowed |
| Protocol-relative redirect (`//evil.com/auth/callback`) | Must not start with `//` |

## Session Management

Sessions are stored in the database using TypeORM. The session entity includes:

- `sessionId`: Unique session identifier (stored in cookie)
- `userId`: User ID from OAuth provider
- `accessToken`: OAuth access token
- `refreshToken`: OAuth refresh token (optional)
- `idToken`: OIDC ID token (used for provider logout)
- `expiresAt`: Session expiration timestamp
- `userInfo`: Cached user information (email, name, roles)

### Session Modes

- **Single mode** (`OAUTH_SESSION_MODE=single`): Only one active session per user. New login invalidates previous session.
- **Multi mode** (`OAUTH_SESSION_MODE=multi`): Multiple concurrent sessions allowed per user.

## Architecture

This library follows Clean Architecture principles:

```
lib/
├── domain/                 # Core business models and interfaces
│   ├── models/            # Session, UserInfo, PKCEChallenge domain models
│   ├── repositories/      # Repository interfaces (ports)
│   └── oauth-options.interface.ts
├── application/           # Use cases and business logic
│   └── usecases/          # Login, Callback, Logout, Refresh, etc.
├── infrastructure/        # External concerns
│   ├── entities/         # TypeORM database entities
│   ├── repositories/     # Repository implementations
│   └── mappers/          # Domain ↔ Entity mapping
└── presentation/          # REST API layer
    ├── controllers/      # AuthController
    ├── dtos/            # Request/Response DTOs
    └── guards/          # SessionGuard
```

## Building

```bash
nx build nestjs-auth-backend
```

## Publishing

```bash
# Build first
nx build nestjs-auth-backend

# Navigate to dist folder
cd dist/libs/nestjs-auth-backend

# Publish to npm
npm publish --access public
```

---

## Base Classes & Interfaces

### IAuthBundle

Interface for the main authentication bundle containing all auth use cases.

**Provides:**
- `initiateLogin` - Start OAuth login flow (accepts optional `redirectUrl`)
- `handleCallback` - Process OAuth callback (returns optional `redirectUrl` in output)
- `getAuthStatus` - Get current auth status
- `getUserInfo` - Get current user info
- `refreshToken` - Refresh tokens
- `logout` - End session
- `createAnonymousSession` - Create anonymous session
- `recoverAnonymousSession` - Recover anonymous session by recovery code

**Usage:** This is typically used internally by the module. Access it if you need custom auth flows.

---

### IApiKeyBundle

Interface for API key management bundle.

**Provides:**
- `createApiKeyUsecase` - Create new API key
- `validateApiKeyUsecase` - Validate an API key
- `revokeApiKeyUsecase` - Revoke an API key
- `listApiKeysUsecase` - List all API keys for a user

**Usage example:**
```typescript
import { IApiKeyBundle } from '@sftech/nestjs-auth-backend';

@Controller('api-keys')
@UseGuards(JwtAuthGuard)
export class ApiKeyController {
    constructor(
        @Inject('IApiKeyBundle')
        private readonly apiKeyBundle: IApiKeyBundle,
    ) {}

    @Post()
    async create(@Body() dto: CreateApiKeyDto, @CurrentUser() user: UserInfo) {
        return this.apiKeyBundle.createApiKeyUsecase.execute({
            userId: user.sub,
            name: dto.name,
            scopes: dto.scopes,
        });
    }

    @Get()
    async list(@CurrentUser() user: UserInfo) {
        return this.apiKeyBundle.listApiKeysUsecase.execute({ userId: user.sub });
    }

    @Delete(':id')
    async revoke(@Param('id') id: string) {
        return this.apiKeyBundle.revokeApiKeyUsecase.execute({ keyId: id });
    }
}
```

---

### ISessionRepository

Repository interface for session management.

**Methods:**
- `create(session: Session): Promise<Session>` - Create new session
- `findById(sessionId: string): Promise<Session | null>` - Find session by ID
- `findByUserId(userId: string): Promise<Session[]>` - Find all sessions for user
- `update(session: Session): Promise<Session>` - Update session
- `delete(sessionId: string): Promise<void>` - Delete session
- `deleteAllForUser(userId: string): Promise<void>` - Delete all sessions for user

**Usage:** Implement this interface if you need custom session storage (e.g., Redis).

---

### IUserRepository

Repository interface for user management.

**Extends:** `IBaseDbRepository<User>`

**Additional methods:**
- `findByEmail(email: string): Promise<User | null>`
- `findByOAuthSub(oauthSub: string): Promise<User | null>`

---

### IOAuthOptions

Configuration interface for OAuth settings. Populated automatically from `app.config.json` by `OAuthOptionsMapper`.

**Properties:**
```typescript
interface IOAuthOptions {
    providerUrl: string;
    authorizationEndpoint: string;
    tokenEndpoint: string;
    userinfoEndpoint: string;
    endSessionEndpoint?: string;
    clientId: string;
    clientSecret: string;
    redirectUri: string;               // OAuth callback URL (this service's own endpoint)
    frontendCallbackUrl?: string;      // Default post-login redirect target
    scope: string;
    sessionMode: 'single' | 'multi';
    sessionMaxAge: number;
    anonymousSessionMaxAge?: number;
    allowedRedirectOrigins?: string[]; // Parsed from OAUTH_ALLOWED_REDIRECT_ORIGINS (v1.4.0+)
    nativeAppScheme?: string;          // Custom URL scheme for native app deep linking (v1.5.0+)
}
```

**`allowedRedirectOrigins` notes:**
- Populated by splitting `OAUTH_ALLOWED_REDIRECT_ORIGINS` on commas and trimming whitespace.
- Each entry is normalized to a bare origin at validation time (e.g. `https://example.com/some/path` becomes `https://example.com`).
- When `undefined` or empty, the origin of `frontendCallbackUrl` is used as the single allowed origin.

**`nativeAppScheme` notes (v1.5.0+):**
- Populated from `OAUTH_NATIVE_APP_SCHEME` in `app.config.json`.
- When `undefined`, `GET /auth/native-relay` returns 404.
- The value is used verbatim to build the redirect URL: `{nativeAppScheme}://auth/callback?session={sessionId}`.

---

### IApiKeyOptions

Configuration interface for API key settings.

**Properties:**
```typescript
interface IApiKeyOptions {
    hashAlgorithm: string;    // e.g., 'sha256'
    keyPrefix: string;        // e.g., 'sk_'
    keyLength: number;        // e.g., 32
}
```

---

### PKCEChallenge

Domain model for PKCE (Proof Key for Code Exchange, RFC 7636) challenges.

**Constructor parameters:**
- `codeVerifier: string` - Random string, 43–128 characters
- `codeChallenge: string` - SHA-256 of codeVerifier, base64url-encoded
- `state: string` - CSRF protection token, minimum 32 characters
- `createdAt: Date` - Creation timestamp (used for 5-minute TTL)
- `redirectUrl?: string` - Optional post-login redirect URL stored alongside the challenge (v1.4.0+)

**Methods:**
- `isValid(): boolean` - Returns `true` if the challenge is less than 5 minutes old
- `isExpired(): boolean` - Returns `true` if the challenge is 5 minutes old or older

**Note:** PKCE challenges are stored in an in-memory cache (`InitiateLoginUseCase.pkceCache`) with a 5-minute TTL. When the challenge expires, any stored `redirectUrl` is also lost; the error redirect falls back to `frontendCallbackUrl` in that case.

---

## Extending the Module

### Custom Session Storage

To use Redis or another storage backend:

```typescript
import { ISessionRepository } from '@sftech/nestjs-auth-backend';
import { Injectable } from '@nestjs/common';
import { Redis } from 'ioredis';

@Injectable()
export class RedisSessionRepository implements ISessionRepository {
    constructor(private readonly redis: Redis) {}

    async create(session: Session): Promise<Session> {
        await this.redis.setex(
            `session:${session.sessionId}`,
            session.maxAge / 1000,
            JSON.stringify(session),
        );
        return session;
    }

    async findById(sessionId: string): Promise<Session | null> {
        const data = await this.redis.get(`session:${sessionId}`);
        return data ? JSON.parse(data) : null;
    }

    // ... implement other methods
}

// Register in module
@Module({
    providers: [
        {
            provide: 'ISessionRepository',
            useClass: RedisSessionRepository,
        },
    ],
})
export class CustomAuthModule {}
```

---

## Changelog

See [CHANGELOG.md](./CHANGELOG.md)

## License

MIT