Skip to main content
integration

Sign in with Ottili - Developer Guide

Complete guide for integrating Sign in with Ottili into your application using OAuth 2.0 and OpenID Connect.

Integrate Ottili ONE authentication into your application using OAuth 2.0 and OpenID Connect (OIDC).

Overview

Sign in with Ottili allows your users to authenticate using their existing Ottili ONE accounts. This provides:

  • Single Sign-On (SSO)*: Users don't need to create new accounts
  • Verified identities*: Email addresses are verified by Ottili ONE
  • Secure authentication*: OAuth 2.0 with PKCE and OIDC
  • User consent*: Users control what data they share

How It Works

Sign in with Ottili uses the OAuth 2.0 Authorization Code flow with PKCE (Proof Key for Code Exchange) and OpenID Connect:

1. User clicks "Sign in with Ottili"* in your app

2. Your app redirects* to Ottili ONE authorization endpoint

3. User authenticates* with Ottili ONE (if not already signed in)

4. User consents* to share their profile with your app

5. Ottili ONE redirects back* to your app with an authorization code

6. Your app exchanges* the code for access and ID tokens

7. Your app uses* the tokens to authenticate the user

Prerequisites

Before you start, you need:

1. Ottili ONE account*: Create one at [ottili.one](https://ottili.one)

2. Registered application*: Register your app in the Ottili ONE Console

3. Client ID and Secret*: Obtained during registration

4. Redirect URI*: Where Ottili ONE will send users after authentication

Step 1: Register Your Application

1. Sign in to [Ottili ONE Console](https://console.ottili.one)

2. Navigate to Settings → OAuth Applications*

3. Click "Register New Application"*

4. Fill in the application details:

- Application Name*: Your app's name (displayed to users)

- Application URL*: Your app's homepage

- Redirect URIs*: Where users are sent after authentication (e.g., https://yourapp.com/auth/callback)

- Scopes*: What data you need (e.g., openid, profile, email)

5. Click "Register"*

6. Save your Client ID and Client Secret* (the secret is only shown once!)

Step 2: Implement the OAuth Flow

Authorization Request

Redirect users to the authorization endpoint:

GET https://auth.ottili.one/oauth/authorize
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=YOUR_REDIRECT_URI
  &scope=openid profile email
  &state=RANDOM_STATE_VALUE
  &code_challenge=CODE_CHALLENGE
  &code_challenge_method=S256

Parameters:*

  • response_type: Must be code
  • client_id: Your application's client ID
  • redirect_uri: Must match one of your registered redirect URIs
  • scope: Space-separated list of scopes (openid is required for OIDC)
  • state: Random value to prevent CSRF attacks
  • code_challenge: PKCE challenge (see below)
  • code_challenge_method: Must be S256

PKCE (Proof Key for Code Exchange)

PKCE prevents authorization code interception attacks:

1. Generate a code verifier*: Random string (43-128 characters)

2. Create code challenge*: SHA256 hash of verifier, base64url-encoded

3. Send challenge* in authorization request

4. Send verifier* in token exchange request

Example (Node.js):*

const crypto = require('crypto');

// Generate code verifier
const codeVerifier = crypto.randomBytes(32).toString('base64url');

// Create code challenge
const codeChallenge = crypto
  .createHash('sha256')
  .update(codeVerifier)
  .digest('base64url');

// Store verifier in session for later use
session.codeVerifier = codeVerifier;

Token Exchange

After the user authorizes your app, Ottili ONE redirects to your redirect_uri with a code parameter:

POST https://auth.ottili.one/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=AUTHORIZATION_CODE
&redirect_uri=YOUR_REDIRECT_URI
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET
&code_verifier=CODE_VERIFIER

Response:*

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "scope": "openid profile email"
}

Verify the ID Token

The ID token is a JWT containing user information:

{
  "iss": "https://auth.ottili.one",
  "sub": "user_123456",
  "aud": "YOUR_CLIENT_ID",
  "exp": 1234567890,
  "iat": 1234567890,
  "email": "user@example.com",
  "email_verified": true,
  "name": "John Doe",
  "preferred_username": "johndoe"
}

Verification steps:*

1. Signature*: Verify using Ottili ONE's public key (from JWKS endpoint)

2. Issuer*: Must be https://auth.ottili.one

3. Audience*: Must match your client ID

4. Expiration*: Must not be expired

5. Nonce*: If you sent a nonce, it must match

JWKS Endpoint:*

GET https://auth.ottili.one/.well-known/jwks.json

Step 3: Use the Access Token

Use the access token to call the UserInfo endpoint:

GET https://auth.ottili.one/oauth/userinfo
Authorization: Bearer ACCESS_TOKEN

Response:*

{
  "sub": "user_123456",
  "email": "user@example.com",
  "email_verified": true,
  "name": "John Doe",
  "preferred_username": "johndoe",
  "picture": "https://auth.ottili.one/avatars/user_123456.jpg"
}

Available Scopes

ScopeDescriptionClaims
openidRequired for OIDCsub
profileUser profile informationname, preferred_username, picture
emailUser email addressemail, email_verified

Code Examples

Node.js (Express)

const express = require('express');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');

const app = express();

const CLIENT_ID = process.env.OTTILI_CLIENT_ID;
const CLIENT_SECRET = process.env.OTTILI_CLIENT_SECRET;
const REDIRECT_URI = 'http://localhost:3000/auth/callback';

const jwks = jwksClient({
  jwksUri: 'https://auth.ottili.one/.well-known/jwks.json'
});

// Start OAuth flow
app.get('/auth/login', (req, res) => {
  const state = crypto.randomBytes(16).toString('hex');
  const codeVerifier = crypto.randomBytes(32).toString('base64url');
  const codeChallenge = crypto
    .createHash('sha256')
    .update(codeVerifier)
    .digest('base64url');

  req.session.state = state;
  req.session.codeVerifier = codeVerifier;

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: 'openid profile email',
    state: state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256'
  });

  res.redirect(`https://auth.ottili.one/oauth/authorize?${params}`);
});

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

  // Verify state
  if (state !== req.session.state) {
    return res.status(400).send('Invalid state');
  }

  // Exchange code for tokens
  const tokenResponse = await fetch('https://auth.ottili.one/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code,
      redirect_uri: REDIRECT_URI,
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      code_verifier: req.session.codeVerifier
    })
  });

  const tokens = await tokenResponse.json();

  // Verify ID token
  const key = await jwks.getSigningKey();
  const publicKey = key.getPublicKey();

  const decoded = jwt.verify(tokens.id_token, publicKey, {
    issuer: 'https://auth.ottili.one',
    audience: CLIENT_ID
  });

  // Get user info
  const userInfoResponse = await fetch('https://auth.ottili.one/oauth/userinfo', {
    headers: { Authorization: `Bearer ${tokens.access_token}` }
  });

  const userInfo = await userInfoResponse.json();

  // Create session
  req.session.user = userInfo;
  req.session.accessToken = tokens.access_token;

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

app.listen(3000);

Python (Flask)

from flask import Flask, redirect, request, session
import requests
import hashlib
import base64
import secrets
from authlib.integrations.flask_client import OAuth

app = Flask(__name__)
app.secret_key = secrets.token_hex(32)

oauth = OAuth(app)
ottili = oauth.register(
    'ottili',
    client_id=os.environ['OTTILI_CLIENT_ID'],
    client_secret=os.environ['OTTILI_CLIENT_SECRET'],
    authorize_url='https://auth.ottili.one/oauth/authorize',
    access_token_url='https://auth.ottili.one/oauth/token',
    userinfo_endpoint='https://auth.ottili.one/oauth/userinfo',
    client_kwargs={'scope': 'openid profile email'}
)

@app.route('/auth/login')
def login():
    # Generate PKCE
    code_verifier = secrets.token_urlsafe(32)
    code_challenge = base64.urlsafe_b64encode(
        hashlib.sha256(code_verifier.encode()).digest()
    ).decode().rstrip('=')

    session['code_verifier'] = code_verifier

    return ottili.authorize_redirect(
        redirect_uri='http://localhost:5000/auth/callback',
        code_challenge=code_challenge,
        code_challenge_method='S256'
    )

@app.route('/auth/callback')
def callback():
    token = ottili.authorize_access_token()
    
    # Get user info
    userinfo = ottili.userinfo()
    
    session['user'] = userinfo
    session['access_token'] = token['access_token']
    
    return redirect('/dashboard')

if __name__ == '__main__':
    app.run(port=5000)

PHP

<?php
session_start();

$CLIENT_ID = getenv('OTTILI_CLIENT_ID');
$CLIENT_SECRET = getenv('OTTILI_CLIENT_SECRET');
$REDIRECT_URI = 'http://localhost:8000/auth/callback.php';

// Start OAuth flow
function startOAuth() {
    global $CLIENT_ID, $REDIRECT_URI;
    
    $state = bin2hex(random_bytes(16));
    $codeVerifier = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
    $codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
    
    $_SESSION['state'] = $state;
    $_SESSION['code_verifier'] = $codeVerifier;
    
    $params = http_build_query([
        'response_type' => 'code',
        'client_id' => $CLIENT_ID,
        'redirect_uri' => $REDIRECT_URI,
        'scope' => 'openid profile email',
        'state' => $state,
        'code_challenge' => $codeChallenge,
        'code_challenge_method' => 'S256'
    ]);
    
    header("Location: https://auth.ottili.one/oauth/authorize?$params");
    exit;
}

// Handle callback
function handleCallback() {
    global $CLIENT_ID, $CLIENT_SECRET, $REDIRECT_URI;
    
    if ($_GET['state'] !== $_SESSION['state']) {
        die('Invalid state');
    }
    
    // Exchange code for tokens
    $ch = curl_init('https://auth.ottili.one/oauth/token');
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
        'grant_type' => 'authorization_code',
        'code' => $_GET['code'],
        'redirect_uri' => $REDIRECT_URI,
        'client_id' => $CLIENT_ID,
        'client_secret' => $CLIENT_SECRET,
        'code_verifier' => $_SESSION['code_verifier']
    ]));
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    
    $tokens = json_decode(curl_exec($ch), true);
    curl_close($ch);
    
    // Get user info
    $ch = curl_init('https://auth.ottili.one/oauth/userinfo');
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        'Authorization: Bearer ' . $tokens['access_token']
    ]);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    
    $userinfo = json_decode(curl_exec($ch), true);
    curl_close($ch);
    
    $_SESSION['user'] = $userinfo;
    $_SESSION['access_token'] = $tokens['access_token'];
    
    header('Location: /dashboard.php');
    exit;
}
?>

Security Best Practices

1. Always Use PKCE

PKCE prevents authorization code interception attacks. It's required for public clients and recommended for all clients.

2. Validate the State Parameter

The state parameter prevents CSRF attacks. Always:

  • Generate a random value
  • Store it in the user's session
  • Verify it matches when handling the callback

3. Verify the ID Token

Always verify:

  • Signature*: Using the JWKS endpoint
  • Issuer*: Must be https://auth.ottili.one
  • Audience*: Must match your client ID
  • Expiration*: Must not be expired

4. Use HTTPS

Always use HTTPS for your redirect URIs in production. HTTP is only allowed for localhost during development.

5. Store Tokens Securely

  • Access tokens*: Store in memory or encrypted storage
  • Refresh tokens*: Store in encrypted storage
  • Never* store tokens in localStorage (XSS vulnerability)

6. Handle Token Expiration

Access tokens expire after 15 minutes. Use refresh tokens to get new access tokens:

const refreshResponse = await fetch('https://auth.ottili.one/oauth/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'refresh_token',
    refresh_token: REFRESH_TOKEN,
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET
  })
});

Testing

Local Development

For local development, you can use http://localhost redirect URIs:

http://localhost:3000/auth/callback
http://127.0.0.1:3000/auth/callback

Test Accounts

Create test accounts in your Ottili ONE Console for testing different scenarios:

  • New user registration
  • Existing user login
  • User with 2FA enabled
  • User with passkeys

OIDC Conformance Testing

Use the [OIDC Conformance Test Suite](https://conformance.openid.net/) to verify your implementation.

Troubleshooting

"Invalid redirect_uri" Error

Cause*: The redirect URI doesn't match your registered URIs.

Solution*:

1. Check your registered redirect URIs in the Console

2. Ensure the URI in your authorization request matches exactly (including protocol and trailing slashes)

"Invalid client" Error

Cause*: The client ID or secret is incorrect.

Solution*:

1. Verify your client ID and secret

2. Check for extra whitespace or missing characters

"Invalid code_verifier" Error

Cause*: The code verifier doesn't match the challenge.

Solution*:

1. Ensure you're using the same verifier that generated the challenge

2. Check that you're using SHA256 and base64url encoding correctly

"Invalid state" Error

Cause*: The state parameter doesn't match.

Solution*:

1. Ensure you're storing the state in the user's session

2. Check that you're comparing the correct values

ID Token Verification Fails

Cause*: The token signature, issuer, or audience is invalid.

Solution*:

1. Verify you're using the correct JWKS endpoint

2. Check that you're validating the issuer and audience

3. Ensure your system clock is synchronized (NTP)

Support

If you need help:

  • Documentation*: [docs.ottili.one](https://docs.ottili.one)
  • Community*: [community.ottili.one](https://community.ottili.one)
  • Email*: support@ottili.one

Changelog

2026-08-02

  • Initial release
  • OAuth 2.0 with PKCE support
  • OpenID Connect support
  • UserInfo endpoint
  • Code examples for Node.js, Python, and PHP

Was this article helpful?