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=S256Parameters:*
response_type: Must becodeclient_id: Your application's client IDredirect_uri: Must match one of your registered redirect URIsscope: Space-separated list of scopes (openidis required for OIDC)state: Random value to prevent CSRF attackscode_challenge: PKCE challenge (see below)code_challenge_method: Must beS256
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_VERIFIERResponse:*
{
"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.jsonStep 3: Use the Access Token
Use the access token to call the UserInfo endpoint:
GET https://auth.ottili.one/oauth/userinfo
Authorization: Bearer ACCESS_TOKENResponse:*
{
"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
| Scope | Description | Claims |
|---|---|---|
openid | Required for OIDC | sub |
profile | User profile information | name, preferred_username, picture |
email | User email address | email, 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/callbackTest 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?
