Python
Add Sign in with PulseID to a Python web app with plain OIDC and PKCE.
This guide adds Sign in with PulseID to a Flask app with requests for the HTTP calls and PyJWT for the ID token. It does not use an OpenID Connect library, so every step of the code flow with PKCE is in front of you, and it carries over to Django, FastAPI or any other framework: only the route and cookie calls change. The quickstart shows the same flow with curl, and the reference lists every endpoint, scope and claim. Nothing here is pinned to a version, and it was written for current releases of Python 3, Flask, requests and PyJWT.
1. Register the app
- In the portal, register a web app. Its redirect URI is your site plus
/auth/callback, for examplehttps://app.example.com/auth/callback. Add your site's home page as a post-logout redirect URI. - Copy the client ID and the client secret. The secret is shown once. If you lose it, rotate it (see Secrets).
Redirect URIs must be https, and PulseID refuses http://localhost. To develop locally, put the app behind an https address: a tunnel, or a local certificate on a name you own. Register that address.
Refresh tokens. A refresh token goes to a verified web app only. Unverified apps get no refresh token. Public apps get no refresh token either, even when their organization is verified. PulseID removes offline_access from the request without an error, so the sign-in still works and the answer holds no refresh_token. The code below handles both. Leave PULSEID_OFFLINE_ACCESS at false until your organization is verified (see Verification).
2. Install and configure
pip install flask requests "PyJWT[crypto]" cryptographyPyJWT[crypto] brings in cryptography, which checks the ES256 signature on PulseID's ID tokens and also seals the session cookie. Set these variables. The issuer is https://id.warpulse.com; the code finds every endpoint from its discovery document at https://id.warpulse.com/.well-known/openid-configuration.
export PULSEID_ISSUER=https://id.warpulse.com
export PULSEID_CLIENT_ID=YOUR_CLIENT_ID
export PULSEID_CLIENT_SECRET=YOUR_CLIENT_SECRET
export PULSEID_REDIRECT_URI=https://app.example.com/auth/callback
# true only for a verified web app: it asks for offline_access and prompt=consent
export PULSEID_OFFLINE_ACCESS=false
export SESSION_SECRET=A_KEY_FROM_THE_COMMAND_ABOVE| Name | What it is |
|---|---|
PULSEID_ISSUER | The issuer. It is the address of PulseID. |
PULSEID_CLIENT_ID | The client ID from the portal. |
PULSEID_CLIENT_SECRET | The client secret from the portal. Keep it on the server. |
PULSEID_REDIRECT_URI | The redirect URI you registered, exactly. The callback path is /auth/callback. |
PULSEID_OFFLINE_ACCESS | true only for a verified web app. It asks for offline_access with prompt=consent, to receive a refresh token. |
SESSION_SECRET | A Fernet key, which seals the cookies. Make one with the command below. |
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"3. The app
The whole app is one file. Read the parts in this order.
- Discovery.
discovery()reads the discovery document once and keeps it, and checks that it names the issuer you expect. The client secret goes to the token endpoint with HTTP Basic (client_secret_basic), which is how a web app is registered. - Sealed cookies. The session and the login-in-flight are encrypted and signed with Fernet, so the browser can neither read nor change them, and the purpose is inside the payload, so a login cookie cannot be played back as a session. The names start with
__Host-, which browsers accept only on https, from your own host, for the whole site. - Sign-in.
/auth/loginmakes a random code verifier, a state and a nonce, and sends the person to PulseID with the PKCE challenge:code_challenge_method=S256, the only method PulseID has. WithPULSEID_OFFLINE_ACCESSon it also sendsoffline_accessandprompt=consent. Sendprompt=consentwhenever you ask foroffline_access: it is what yields the refresh token. The verifier, state and nonce wait in a sealed cookie that lasts ten minutes, until the person comes back. - The return. PulseID sends the person to
/auth/callbackwithcode,stateandiss. The handler checks the state and the issuer, then posts the code with the PKCE verifier and the client secret (the code works once and for 60 seconds). - The ID token.
check_id_tokenchecks the signature against the keys at thejwks_uri, then the issuer, the audience (your client ID), the expiry and the nonce. Restrict the algorithm to ES256, which is what PulseID signs with. The ID token is a signed JWT with a 10 minute life, and it is the identity:subis the person, stable for that account, andemailandnamecome with theemailandprofilescopes. Usesubas your key for the person, not the email address.
import base64
import functools
import hashlib
import hmac
import json
import os
import re
import secrets
import threading
import time
from urllib.parse import quote, urlencode, urlparse
import jwt # PyJWT
import requests
from cryptography.fernet import Fernet, InvalidToken
from flask import Flask, redirect, render_template_string, request
ISSUER = os.environ["PULSEID_ISSUER"].rstrip("/")
CLIENT_ID = os.environ["PULSEID_CLIENT_ID"]
CLIENT_SECRET = os.environ["PULSEID_CLIENT_SECRET"]
REDIRECT_URI = os.environ["PULSEID_REDIRECT_URI"]
# Refresh tokens come with offline_access, and only a verified web app gets one.
OFFLINE = os.environ.get("PULSEID_OFFLINE_ACCESS") == "true"
SCOPE = "openid profile email offline_access" if OFFLINE else "openid profile email"
SESSION_SECONDS = 30 * 24 * 3600
LOGIN_SECONDS = 600
REFRESH_MARGIN_SECONDS = 60
REFRESH_SHARE_SECONDS = 30
SESSION_COOKIE = "__Host-session"
LOGIN_COOKIE = "__Host-login"
app = Flask(__name__)
vault = Fernet(os.environ["SESSION_SECRET"])
@functools.cache
def discovery():
"""The endpoints and the signing keys, read once and kept. A failure is not kept."""
document = requests.get(ISSUER + "/.well-known/openid-configuration", timeout=10).json()
if document["issuer"] != ISSUER:
raise RuntimeError("The discovery document names another issuer")
return document
@functools.cache
def jwks():
return jwt.PyJWKClient(discovery()["jwks_uri"])
def basic_auth():
# The client secret goes to the token endpoint with HTTP Basic (client_secret_basic), which is how a web app is registered.
return (CLIENT_ID, CLIENT_SECRET)
# A sealed cookie: the payload is encrypted and signed (Fernet), so the browser can neither read nor change it. The purpose is
# inside the payload, so a login cookie cannot be played back as a session.
def seal(purpose, data, seconds):
body = json.dumps({"p": purpose, "exp": int(time.time()) + seconds, "d": data})
return vault.encrypt(body.encode()).decode()
def unseal(purpose, value):
if not value:
return None
try:
body = json.loads(vault.decrypt(value.encode()))
except (InvalidToken, ValueError):
return None
if body.get("p") != purpose or body.get("exp", 0) < time.time():
return None
return body["d"]
def set_cookie(response, name, value, seconds):
response.set_cookie(name, value, max_age=seconds, secure=True, httponly=True, samesite="Lax", path="/")
return response
# Only ever send the person back to a path on this site: one slash, then no second slash, backslash, space or control character
# (a browser drops a tab, so "/<tab>/evil.com" would mean "//evil.com").
def safe_path(value):
return value if value and re.fullmatch(r"/(?![/\\])[^\x00-\x20\x7f\\]*", value) else "/"
def pkce_challenge(verifier):
digest = hashlib.sha256(verifier.encode()).digest()
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
@app.get("/auth/login")
def login():
verifier = secrets.token_urlsafe(48) # 64 characters, inside the 43 to 128 the standard allows
state = secrets.token_urlsafe(24)
nonce = secrets.token_urlsafe(24)
params = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": SCOPE,
"state": state,
"nonce": nonce,
"code_challenge": pkce_challenge(verifier),
"code_challenge_method": "S256",
}
if OFFLINE:
params["prompt"] = "consent" # offline_access is only honoured together with prompt=consent
url = discovery()["authorization_endpoint"] + "?" + urlencode(params)
# Remember the secrets of this sign-in in a short-lived sealed cookie, until the person comes back.
login_state = {"verifier": verifier, "state": state, "nonce": nonce, "return_to": safe_path(request.args.get("returnTo"))}
return set_cookie(redirect(url, 303), LOGIN_COOKIE, seal("login", login_state, LOGIN_SECONDS), LOGIN_SECONDS)
def check_id_token(id_token, nonce):
"""The signature through the JWKS, the issuer, the audience, the expiry, and the nonce. PulseID signs ID tokens with ES256."""
key = jwks().get_signing_key_from_jwt(id_token).key
claims = jwt.decode(
id_token,
key,
algorithms=["ES256"],
audience=CLIENT_ID,
issuer=ISSUER,
leeway=10,
options={"require": ["exp", "iat", "iss", "aud", "sub"]},
)
if not hmac.compare_digest(str(claims.get("nonce", "")).encode(), nonce.encode()):
raise jwt.InvalidTokenError("nonce does not match")
return claims
def new_session(tokens, claims):
return {
"sub": claims["sub"],
"email": claims.get("email"),
"name": claims.get("name"),
"access_token": tokens["access_token"],
"refresh_token": tokens.get("refresh_token"),
"access_expires": int(time.time()) + int(tokens.get("expires_in", 600)),
}
@app.get("/auth/callback")
def callback():
login_state = unseal("login", request.cookies.get(LOGIN_COOKIE))
failed = set_cookie(app.response_class("Sign-in failed. Start again.", status=400), LOGIN_COOKIE, "", 0)
if not login_state or "code" not in request.args or request.args.get("iss", ISSUER) != ISSUER:
return failed
if not hmac.compare_digest(request.args.get("state", "").encode(), login_state["state"].encode()):
return failed
try:
answer = requests.post(
discovery()["token_endpoint"],
auth=basic_auth(),
data={
"grant_type": "authorization_code",
"code": request.args["code"],
"redirect_uri": REDIRECT_URI,
"code_verifier": login_state["verifier"],
},
timeout=10,
)
answer.raise_for_status()
tokens = answer.json()
claims = check_id_token(tokens["id_token"], login_state["nonce"])
except (requests.RequestException, jwt.PyJWTError, KeyError, ValueError) as error:
app.logger.warning("PulseID sign-in failed: %s", error)
return failed
response = redirect(safe_path(login_state["return_to"]), 303)
set_cookie(response, LOGIN_COOKIE, "", 0)
return set_cookie(response, SESSION_COOKIE, seal("session", new_session(tokens, claims), SESSION_SECONDS), SESSION_SECONDS)
# A refresh token works once: the answer holds a new one. Use a token a second time and PulseID ends the whole sign-in.
# Requests that arrive together carry the same token, so they share one refresh. (One lock, one server process. With
# several, refresh behind a lock they all share.)
_refreshes = {}
_refresh_lock = threading.Lock()
def refresh_once(refresh_token):
with _refresh_lock:
now = time.time()
for token in [t for t, (at, _) in _refreshes.items() if now - at > REFRESH_SHARE_SECONDS]:
del _refreshes[token]
if refresh_token not in _refreshes:
answer = requests.post(
discovery()["token_endpoint"],
auth=basic_auth(),
data={"grant_type": "refresh_token", "refresh_token": refresh_token},
timeout=10,
)
answer.raise_for_status()
_refreshes[refresh_token] = (now, answer.json())
return _refreshes[refresh_token][1]
@app.get("/auth/refresh")
def refresh():
return_to = safe_path(request.args.get("returnTo"))
login_url = "/auth/login?returnTo=" + quote(return_to, safe="")
session = unseal("session", request.cookies.get(SESSION_COOKIE))
if not session or not session.get("refresh_token"):
return redirect(login_url, 303)
try:
tokens = refresh_once(session["refresh_token"])
except requests.RequestException:
# invalid_grant: the person signed out of PulseID, revoked the app, or the 30 days ended. Start again.
return set_cookie(redirect(login_url, 303), SESSION_COOKIE, "", 0)
session.update(
access_token=tokens["access_token"],
refresh_token=tokens.get("refresh_token", session["refresh_token"]),
access_expires=int(time.time()) + int(tokens.get("expires_in", 600)),
)
return set_cookie(redirect(return_to, 303), SESSION_COOKIE, seal("session", session, SESSION_SECONDS), SESSION_SECONDS)
@app.get("/account")
def account():
session = unseal("session", request.cookies.get(SESSION_COOKIE))
here = quote("/account", safe="")
if not session:
return redirect("/auth/login?returnTo=" + here, 303)
if session.get("refresh_token") and session["access_expires"] - time.time() <= REFRESH_MARGIN_SECONDS:
return redirect("/auth/refresh?returnTo=" + here, 303)
who = session.get("name") or session.get("email") or session["sub"]
return render_template_string(
'<p>Signed in as {{ who }}.</p><form action="/auth/logout" method="post"><button type="submit">Sign out</button></form>',
who=who,
)
# POST only, from a form on your own site, so another site cannot sign people out.
@app.post("/auth/logout")
def logout():
allowed = urlparse(REDIRECT_URI)
if request.headers.get("Origin") != allowed.scheme + "://" + allowed.netloc:
return "Forbidden", 403
meta = discovery()
session = unseal("session", request.cookies.get(SESSION_COOKIE))
if session and session.get("refresh_token"):
try: # Ends the grant at PulseID. If it fails, the token still expires on its own.
requests.post(
meta["revocation_endpoint"],
auth=basic_auth(),
data={"token": session["refresh_token"], "token_type_hint": "refresh_token"},
timeout=10,
)
except requests.RequestException:
pass
# PulseID asks the person to confirm, ends their PulseID session in this browser, and sends them back to a
# post-logout redirect URI you registered for the app.
back = allowed.scheme + "://" + allowed.netloc + "/"
url = meta["end_session_endpoint"] + "?" + urlencode({"client_id": CLIENT_ID, "post_logout_redirect_uri": back})
return set_cookie(redirect(url, 303), SESSION_COOKIE, "", 0)Start it with flask --app app run for development, behind your https address, and use a production server such as Gunicorn behind a proxy that ends TLS when you deploy.
4. The access token
The access token is opaque. It is good for the userinfo endpoint for 10 minutes and tells you nothing by itself. If all you need is who the person is, keep sub and the profile in the session and drop the tokens.
5. Refresh
With a refresh token, /account hands over to /auth/refresh when 60 seconds or fewer are left on the access token. That route trades the refresh token for new tokens, stores them in a fresh cookie and sends the person back. The refresh token lasts at most 30 days from the sign-in it came from, so the session cookie lasts that long too. When a refresh fails with invalid_grant, the person signed out of PulseID, revoked the app, or the 30 days ended. The route clears the cookie and starts a new sign-in.
A refresh token works once. Every refresh returns a new refresh token and uses up the old one. Use a refresh token a second time and PulseID ends the whole sign-in, and the person has to sign in again. A page that loads with several requests at once sends the same token several times, so refresh_once makes them share one refresh and keeps its result for 30 seconds. That holds in one server process, with one lock. If you run several processes or instances, take a lock that they all share, such as one in your database.
6. Sign out
/auth/logout accepts a POST from your own site only, so another site cannot sign people out. It revokes the refresh token, clears the cookie and sends the browser to PulseID's end-session endpoint, which asks the person to confirm and then ends their PulseID session in that browser. The person comes back to the post-logout redirect URI you registered. An address that is not registered is refused, so register your home page first.
Try it
- Start the app behind your https address and open
/account. You go to PulseID, sign in, see the consent screen for your organization, and come back signed in. - Open the browser's cookies for your site. You see
__Host-sessionand its value is unreadable. - Sign out. Opening
/accountagain starts a new sign-in.
If the callback answers "Sign-in failed", read your server log: invalid_client means the client ID or secret is wrong, revoked or the app is paused, and invalid_grant means the code was used or expired, or the redirect URI or verifier did not match what was sent. The reference lists the errors, and Secrets covers rotating the secret without an outage.