npm package: @instawork/oauth-login
Description: Easily add Login with Instawork OAuth2 PKCE flow to your app.
Author: Instawork
Latest version: 1.2.1

# @instawork/oauth-login

[![npm version](https://badge.fury.io/js/@instawork%2Foauth-login.svg)](https://badge.fury.io/js/@instawork%2Foauth-login)
[![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg)](http://www.typescriptlang.org/)

Easily add "Login with Instawork" OAuth2 PKCE flow to your web application. This package provides a secure, TypeScript-first implementation of OAuth 2.0 with PKCE (Proof Key for Code Exchange) for client-side applications.

## ✨ Features

- 🔒 **Secure PKCE Implementation** - Protects against authorization code interception attacks
- 📘 **TypeScript First** - Full type safety with comprehensive interfaces and JSDoc
- ⚛️ **React Ready** - Pre-built React components and hooks
- 🎨 **Customizable UI** - Styled button component with customization options
- 🚀 **Easy Integration** - Simple API with sensible defaults
- 🛡️ **Error Handling** - Comprehensive error handling with detailed messages
- 📱 **Browser Support** - Works in all modern browsers with Web Crypto API

## 📦 Installation

```bash
npm install @instawork/oauth-login
```

## 🚀 Quick Start

### Generate client id
Follow instructions here: https://instawork.atlassian.net/wiki/spaces/EN/pages/4194697219/OAuth+Setup

### React Component (Recommended)

```tsx
import { InstaworkLoginButton, setupOAuthListener, handleOAuthRedirect } from '@instawork/oauth-login';
import { useEffect } from 'react';

function LoginPage() {
  // Set up listener for popup OAuth callback
  useEffect(() => {
    const removeListener = setupOAuthListener(
      (tokens) => {
        // Store tokens and redirect user
        localStorage.setItem('access_token', tokens.access_token);
        window.location.href = '/dashboard';
      },
      (error) => console.error('Login failed:', error)
    );
    return removeListener; // Cleanup on unmount
  }, []);

  return (
    <InstaworkLoginButton
      clientId="YOUR_CLIENT_ID"
      redirectUri="https://yourapp.com/oauth/callback"
      onError={(error) => console.error('Login failed:', error)}
    />
  );
}

// On your callback page (handles popup callback)
function CallbackPage() {
  React.useEffect(() => {
    handleOAuthRedirect({
      clientId: 'YOUR_CLIENT_ID',
      redirectUri: 'https://yourapp.com/oauth/callback',
      serverCallbackUrl: 'https://yourapp.com/api/oauth/callback'
    }).catch(error => {
      console.error('OAuth callback failed:', error);
    });
  }, []);

  return <div>Processing login...</div>;
}
```

### Vanilla JavaScript/TypeScript

```typescript
import { loginWithInstawork, setupOAuthListener, handleOAuthRedirect, isOAuthCallback } from '@instawork/oauth-login';

// Set up listener for popup OAuth callback (on main page)
setupOAuthListener(
  (tokens) => {
    console.log('Login successful!', tokens);
    localStorage.setItem('access_token', tokens.access_token);
    window.location.href = '/dashboard';
  },
  (error) => {
    console.error('Login failed:', error);
  }
);

// Initiate login
document.getElementById('login-btn')?.addEventListener('click', async () => {
  try {
    await loginWithInstawork({
      clientId: 'YOUR_CLIENT_ID',
      redirectUri: 'https://yourapp.com/oauth/callback'
      // scope: 'openid profile email' // Optional - only include if needed
    });
  } catch (error) {
    console.error('Login initiation failed:', error);
  }
});

// Handle callback (on your redirect page - callback.html)
if (isOAuthCallback()) {
  handleOAuthRedirect({
    clientId: 'YOUR_CLIENT_ID',
    redirectUri: 'https://yourapp.com/oauth/callback',
    serverCallbackUrl: 'https://yourapp.com/api/oauth/callback'
  }).catch(error => {
    console.error('OAuth callback failed:', error);
  });
}
```

## 📚 API Reference

### `loginWithInstawork(config)`

Initiates the OAuth login flow by opening Instawork's authorization server in a popup window.

**Note:** The login page opens in a popup window to preserve the application state and localStorage. Make sure your application allows popups.

```typescript
interface OAuthLoginConfig {
  clientId: string;        // Your OAuth client ID
  redirectUri: string;     // Registered redirect URI
  scope?: string;          // OAuth scopes (optional - no default)
  baseUrl?: string;        // Custom base URL (optional, defaults to https://app.instawork.com)
}

// Optional: Listen for authentication success in the parent window
window.addEventListener('message', (event) => {
  if (event.origin === window.location.origin && event.data.type === 'oauth-success') {
    console.log('User authenticated!', event.data.tokens);
    // Update your application state here
  }
});
```

### `handleOAuthRedirect(config)`

Handles the OAuth callback and sends the authorization code to your server for token exchange.

**Important:** This function now requires a server-side endpoint to handle the actual token exchange with Instawork. This is more secure as it keeps your OAuth flow server-side.

```typescript
interface OAuthRedirectConfig {
  clientId: string;           // Your OAuth client ID  
  redirectUri: string;        // Same redirect URI used in login
  serverCallbackUrl: string;  // Your server endpoint to handle token exchange
  baseUrl?: string;           // Custom base URL (optional, defaults to https://app.instawork.com)
}

interface OAuthTokenResponse {
  access_token: string;    // API access token
  token_type: string;      // Token type (usually 'Bearer')
  expires_in: number;      // Token expiration in seconds
  refresh_token?: string;  // Refresh token (if available)
  id_token?: string;       // JWT ID token with user info
  scope?: string;          // Granted scopes
}
```

### `InstaworkLoginButton`

Pre-styled React button component with built-in OAuth handling.

```typescript
interface InstaworkLoginButtonProps {
  clientId: string;
  redirectUri: string; 
  scope?: string;                              // Optional OAuth scopes
  baseUrl?: string;                            // Custom base URL (optional, defaults to https://app.instawork.com)
  children?: React.ReactNode;                  // Button text
  className?: string;                          // CSS class
  style?: React.CSSProperties;                 // Inline styles
  disabled?: boolean;                          // Disabled state
  loading?: boolean;                           // Loading state
  onClick?: () => void | Promise<void>;        // Pre-login callback
  onError?: (error: Error) => void;            // Error handler
}
```

### `useInstaworkLogin()` Hook

React hook for managing OAuth login state.

```typescript
const { login, isLoading, error, clearError } = useInstaworkLogin();

// Usage
const handleLogin = () => {
  login({
    clientId: 'YOUR_CLIENT_ID',
    redirectUri: 'https://yourapp.com/oauth/callback',
  });
};
```

### `setupOAuthListener(onSuccess, onError?)`

Set up a listener to handle OAuth callbacks from popup windows. Call this once when your application initializes.

```typescript
function setupOAuthListener(
  onSuccess: (tokens: OAuthTokenResponse) => void,
  onError?: (error: Error) => void
): () => void

// Usage
const removeListener = setupOAuthListener(
  (tokens) => {
    console.log('Authenticated!', tokens);
    // Handle successful authentication
  },
  (error) => {
    console.error('Auth failed:', error);
    // Handle authentication error
  }
);

// Returns a cleanup function to remove the listener
// Call this when your component unmounts
removeListener();
```

### `isOAuthCallback()`

Utility function to check if the current page is handling an OAuth callback.

```typescript
if (isOAuthCallback()) {
  // Handle the OAuth callback
}
```

## 🎨 Customization

### Custom Button Styling

```tsx
<InstaworkLoginButton
  clientId="YOUR_CLIENT_ID"
  redirectUri="https://yourapp.com/oauth/callback"
  style={{
    backgroundColor: '#custom-color',
    borderRadius: '8px',
    fontSize: '16px',
    padding: '16px 32px'
  }}
  className="my-custom-class"
>
  Sign in with Instawork
</InstaworkLoginButton>
```

### Error Handling

```typescript
const handleOAuthError = (error: Error) => {
  if (error.message.includes('Invalid state')) {
    // Handle CSRF attack attempt
    alert('Security error detected. Please try again.');
  } else if (error.message.includes('Token exchange failed')) {
    // Handle token exchange errors
    alert('Login failed. Please try again.');
  } else {
    // Handle other errors
    console.error('OAuth error:', error);
  }
};
```

## 🔧 Configuration

### Environment Setup

1. Register your application with Instawork to get your `clientId`
2. Configure your redirect URI in the Instawork OAuth application settings  
3. That's it! The OAuth URLs are automatically configured for Instawork

### Custom Base URL Configuration

For staging, development, or custom environments, you can specify a custom base URL:

```typescript
// Using custom base URL for staging
await loginWithInstawork({
  clientId: 'YOUR_CLIENT_ID',
  redirectUri: 'https://yourapp.com/oauth/callback',
  baseUrl: 'https://staging.instawork.com'
});

// Using custom base URL with port
await loginWithInstawork({
  clientId: 'YOUR_CLIENT_ID',
  redirectUri: 'https://yourapp.com/oauth/callback',
  baseUrl: 'https://staging.instawork.com:8080'
});

// Also for callback handling (must use same base URL)
const tokens = await handleOAuthRedirect({
  clientId: 'YOUR_CLIENT_ID',
  redirectUri: 'https://yourapp.com/oauth/callback',
  baseUrl: 'https://staging.instawork.com:8080' // Same as login
});
```

### Server-Side Implementation

The `handleOAuthRedirect` function sends the authorization code to your server for token exchange. You need to implement a server endpoint to handle this.

**Request from Client:**
```typescript
POST /api/oauth/callback
Content-Type: application/json

{
  "code": "authorization_code_from_oauth",
  "redirect_uri": "https://yourapp.com/oauth/callback",
  "client_id": "your_client_id",
  "code_verifier": "pkce_code_verifier",
  "state": "state_value",
  "base_url": "https://app.instawork.com"
}
```

**Example Server Implementation (Node.js/Express):**
```javascript
app.post('/api/oauth/callback', async (req, res) => {
  try {
    const { code, redirect_uri, client_id, code_verifier, base_url } = req.body;
    
    // Exchange authorization code for tokens
    const tokenUrl = `${base_url}/oauth2/token/`;
    const response = await fetch(tokenUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Accept': 'application/json'
      },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code,
        redirect_uri,
        client_id,
        code_verifier,
        // client_secret: process.env.OAUTH_CLIENT_SECRET, // If using confidential client
      })
    });

    if (!response.ok) {
      const errorText = await response.text();
      return res.status(response.status).json({ 
        error: 'Token exchange failed',
        details: errorText 
      });
    }

    const tokens = await response.json();
    
    // Store tokens securely (e.g., in session, database)
    // Create user session
    req.session.accessToken = tokens.access_token;
    req.session.userId = parseJWT(tokens.id_token).sub;
    
    // Return tokens to client (or just success status)
    res.json(tokens);
  } catch (error) {
    console.error('OAuth callback error:', error);
    res.status(500).json({ error: 'Internal server error' });
  }
});
```

**Example Server Implementation (Python/Flask):**
```python
@app.route('/api/oauth/callback', methods=['POST'])
def oauth_callback():
    try:
        data = request.get_json()
        code = data['code']
        redirect_uri = data['redirect_uri']
        client_id = data['client_id']
        code_verifier = data['code_verifier']
        base_url = data['base_url']
        
        # Exchange authorization code for tokens
        token_url = f"{base_url}/oauth2/token/"
        response = requests.post(token_url, data={
            'grant_type': 'authorization_code',
            'code': code,
            'redirect_uri': redirect_uri,
            'client_id': client_id,
            'code_verifier': code_verifier,
            # 'client_secret': os.environ.get('OAUTH_CLIENT_SECRET'),  # If using confidential client
        }, headers={
            'Accept': 'application/json'
        })
        
        if not response.ok:
            return jsonify({'error': 'Token exchange failed', 'details': response.text}), response.status_code
        
        tokens = response.json()
        
        # Store tokens securely (e.g., in session, database)
        session['access_token'] = tokens['access_token']
        session['user_id'] = parse_jwt(tokens['id_token'])['sub']
        
        # Return tokens to client
        return jsonify(tokens)
    except Exception as e:
        print(f'OAuth callback error: {e}')
        return jsonify({'error': 'Internal server error'}), 500
```

### Popup Window Behavior

The OAuth login flow opens in a popup window to preserve your application's state and localStorage. This ensures:
- ✅ PKCE codes remain accessible in the parent window
- ✅ Your application state remains intact
- ✅ Better user experience with automatic popup closure after authentication
- ✅ Token exchange happens in the parent window context with proper localStorage access

**Handling Popup Blockers:**

If a popup blocker prevents the login window from opening, the library will throw an error. You can handle this gracefully:

```typescript
try {
  await loginWithInstawork({ clientId, redirectUri, serverCallbackUrl });
} catch (error) {
  if (error.message.includes('popup')) {
    alert('Please allow popups to continue with login');
  }
}
```

**Listening for Authentication Success:**

Use the `setupOAuthListener` function to handle OAuth callbacks from the popup:

```typescript
import { setupOAuthListener } from '@instawork/oauth-login';

// Set up the listener when your app initializes
const removeListener = setupOAuthListener(
  (tokens) => {
    console.log('User authenticated!', tokens);
    // Update your application state, redirect user, etc.
    localStorage.setItem('access_token', tokens.access_token);
    window.location.href = '/dashboard';
  },
  (error) => {
    console.error('Authentication failed:', error);
    alert('Login failed: ' + error.message);
  }
);

// Clean up when needed (e.g., component unmount)
// removeListener();
```

**How It Works:**

1. Parent window calls `loginWithInstawork()`
   - Generates PKCE codes and state
   - Stores session in **in-memory Map** (indexed by state parameter)
   - Also stores in localStorage as fallback
2. Popup window opens for OAuth authentication
3. OAuth provider redirects to callback URL in the popup
4. Popup detects `oauth_popup_mode` flag and extracts authorization code
5. Popup sends code + state to parent via `BroadcastChannel` (or `window.postMessage` as fallback)
6. Popup closes automatically
7. Parent's `setupOAuthListener` receives the callback data
8. Parent looks up session from **in-memory Map using state parameter**
9. Parent exchanges code for tokens using the in-memory PKCE codes
10. Your callback receives the tokens
11. Session is cleaned up from memory automatically

**Why In-Memory Storage?**

Popup windows and parent windows have **separate localStorage contexts** in many browsers, even on the same origin. By storing PKCE codes in the parent window's memory (using a `Map`), we ensure they're always accessible when needed. The state parameter acts as the key to look up the correct session.

**Benefits:**
- ✅ Works regardless of localStorage sharing behavior
- ✅ More secure (data only in memory, never persisted)
- ✅ Automatic cleanup of old sessions (>10 minutes)
- ✅ No race conditions or timing issues

### Security Considerations

- Always validate the `state` parameter to prevent CSRF attacks (handled automatically by the client library)  
- **Never expose your client secret** - keep it on the server side only
- Store tokens securely on the server (use secure HTTP-only cookies or server-side sessions)
- Use HTTPS in production for all redirect URIs and API endpoints
- The PKCE flow provides additional security for client-side applications
- Implement proper session management and token refresh logic on your server
- Consider implementing rate limiting on your OAuth callback endpoint
- **Validate postMessage origins** - Always verify `event.origin` when listening for OAuth success messages

## 🛠️ Development

```bash
# Install dependencies
npm install

# Build the package
npm run build

# Development mode with file watching
npm run dev
```

## 📚 Examples

Check out the comprehensive examples in the [`examples/`](./examples/) directory:

- **[Vanilla JavaScript](./examples/vanilla-js/)** - Complete HTML/JS implementation
- **[React + TypeScript](./examples/react/)** - Modern React app with hooks and components

### Quick Start
```bash
# Choose your example
cd examples/vanilla-js  # or examples/react

# Install dependencies (auto-copies package files)
npm install

# Start development server
npm start

# Visit http://localhost:3000
```

Both examples include:
- ✅ Complete setup instructions
- ✅ Working OAuth login and callback flows  
- ✅ Error handling and validation
- ✅ Production deployment guidance
- ✅ Comprehensive documentation

## 📄 License

MIT © Instawork

## 🤝 Contributing

Issues and pull requests are welcome! Please read our contributing guidelines before submitting.

## 📞 Support

For OAuth application setup and API questions, please contact Instawork support.