Sign in with Aurora Account
Aurora Account is the one account behind every Aurora product. A service run by someone else can let people sign in with it too, through standard OpenID Connect. This page is everything such a service needs. Signing in with Aurora Account is open to services we have registered: section 2 says what to send us.
The short version
- OpenID Connect, authorization code flow, PKCE with
S256. - Your server proves itself with a JWT signed by your own RSA key (
private_key_jwt). There is no client secret. - You learn who the person is: an identifier that is yours alone, and their email and name if you ask. Nothing else.
- The person agrees once, on a consent screen, and can disconnect your service at any time.
- There are no refresh tokens and no logout notices: keep your own session.
Quickstart
Two small, complete web servers that sign a person in with Aurora Account and show what it says about them: one in Python, one in Node.js. Each does what this page describes, in its order: discovery, the authorization request with PKCE, the token request with a client assertion, the ID token checked against the key set, and userinfo. Both are under the MIT licence: copy what you need.
- Make your key pair, and send us the public key with your redirect URI (section 2). For your own machine,
that can be
https://localhost:8443/callback. We send you back yourclient_id. - Download one example’s files, and install what its README names.
- Make a certificate for
localhost(the README has themkcertrecipe), setCLIENT_ID,PRIVATE_KEY_PATHandREDIRECT_URI, and start it. - Open
https://localhost:8443/and sign in.
Note. Each keeps the sign-ins under way in memory, in one process: an example to read and run, not a production server.
Python 3.10 or later, with requests and PyJWT. Its files:
app.py,
requirements.txt,
README.md and
LICENSE.
"""Sign in with Aurora Account: a complete example in Python.
A small web server that signs a person in as https://aurora-ws.com/developers
describes: the authorization code flow with PKCE, a private_key_jwt client assertion
for the token request, the ID token checked against Aurora Account's key set, and
userinfo. It keeps the sign-ins under way in memory: an example to read and run, not
a production server. README.md says how to run it. MIT licence: LICENSE.
"""
import base64
import hashlib
import hmac
import html
import json
import os
import secrets
import ssl
import sys
import time
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlencode, urlsplit
import jwt # PyJWT, with its cryptography extra
import requests
# Name your own service here: Cloudflare in front of Aurora Account turns away some
# libraries' default User-Agent (section 10).
USER_AGENT = "AuroraSignInExample/1.0 (Python; +https://aurora-ws.com/developers)"
SCOPE = "openid email profile"
PENDING_FOR = 600 # seconds a sign-in may take, from /login to /callback
JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
def make_assertion(client_id: str, issuer: str, private_pem: bytes) -> str:
"""The client assertion that proves a token request is yours (section 5): signed
with your private key, valid for a minute, and accepted once."""
now = int(time.time())
claims = {
"iss": client_id, # you
"sub": client_id, # you, again
"aud": issuer, # the issuer itself, as one string
"iat": now,
"exp": now + 60, # a minute is plenty
"jti": str(uuid.uuid4()), # new every time: an assertion works once
}
return jwt.encode(claims, private_pem, algorithm="RS256")
def verify_id_token(id_token: str, keys: jwt.PyJWKClient, issuer: str, client_id: str,
nonce: str) -> dict:
"""The ID token's claims, once it is shown to be Aurora Account's, for you, and for
this sign-in (section 6). Raises jwt.InvalidTokenError otherwise."""
# The signature, by the key the token's header names, RS256 only.
key = keys.get_signing_key_from_jwt(id_token)
# Then iss, aud and exp. strict_aud refuses an aud that is a list, and the leeway
# forgives a clock a little off Aurora Account's, as Aurora Account forgives yours.
claims = jwt.decode(id_token, key.key, algorithms=["RS256"], issuer=issuer,
audience=client_id, leeway=30,
options={"require": ["iss", "aud", "exp", "sub", "nonce"],
"strict_aud": True})
# And the nonce this sign-in sent.
if not hmac.compare_digest(str(claims["nonce"]).encode(), nonce.encode()):
raise jwt.InvalidTokenError("The ID token's nonce is not the one sent.")
return claims
def setting(name: str) -> str:
if not os.environ.get(name):
sys.exit(f"Set {name}: README.md says how.")
return os.environ[name]
def cookie_of(header: str, name: str) -> str:
"""One cookie from a Cookie header, read by hand: http.cookies gives up on a whole
header for one cookie it does not like, and on localhost every dev server's
cookies arrive here."""
for part in header.split(";"):
key, _, value = part.strip().partition("=")
if key == name:
return value
return ""
def page(title: str, body: str) -> bytes:
return ('<!doctype html><html lang="en"><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
f"<title>{title}</title><h1>{title}</h1>{body}").encode()
def shown(value) -> str:
return f"<pre>{html.escape(json.dumps(value, indent=2, ensure_ascii=False))}</pre>"
class Example:
"""The configuration, Aurora Account's endpoints, and the sign-ins under way."""
def __init__(self):
self.issuer = os.environ.get("AURORA_ISSUER", "https://account.aurora-ws.com/o")
self.client_id = setting("CLIENT_ID")
self.redirect_uri = setting("REDIRECT_URI")
with open(setting("PRIVATE_KEY_PATH"), "rb") as file:
self.private_pem = file.read()
try: # a key that cannot sign fails now, not at the first sign-in
make_assertion(self.client_id, self.issuer, self.private_pem)
except Exception as error:
sys.exit(f"PRIVATE_KEY_PATH holds no RSA private key that signs: {error}")
self.http = requests.Session()
self.http.headers["User-Agent"] = USER_AGENT
# The endpoints come from the discovery document, which names the issuer.
found = self.http.get(f"{self.issuer}/.well-known/openid-configuration",
timeout=10)
found.raise_for_status()
self.meta = found.json()
# It must name the issuer you configured, exactly.
if self.meta["issuer"] != self.issuer:
sys.exit(f"The discovery document names the issuer {self.meta['issuer']}.")
# The key set: cached, and fetched again for a token whose key it does not
# hold (section 6).
self.keys = jwt.PyJWKClient(self.meta["jwks_uri"],
headers={"User-Agent": USER_AGENT})
self.pending = {} # state -> (code_verifier, nonce, when it started)
def cookie(self, state: str, max_age: int) -> str:
secure = "; Secure" if self.redirect_uri.startswith("https://") else ""
return (f"aurora_state={state}; Max-Age={max_age}; Path=/; HttpOnly; "
f"SameSite=Lax{secure}")
class Handler(BaseHTTPRequestHandler):
timeout = 10 # seconds a client may stay silent
def handle(self):
# Over HTTPS, the handshake happens here, in this request's own thread and under
# its timeout, so that one silent client cannot hold up the others.
if isinstance(self.connection, ssl.SSLSocket):
try:
self.connection.do_handshake()
except OSError as error:
return self.log_error("TLS handshake failed: %s", error)
super().handle()
def log_request(self, code="-", size="-"):
# The request line without its query: the callback's carries the code and state.
line = self.requestline.split("?")[0]
self.log_message('"%s" %s', line, getattr(code, "value", code))
def do_GET(self):
url = urlsplit(self.path)
query = {name: values[0] for name, values in parse_qs(url.query).items()}
routes = {"/": self.home, "/login": self.login, "/callback": self.callback}
if url.path not in routes:
return self.answer(404, page("Not found", ""))
routes[url.path](self.server.example, query)
def answer(self, status: int, body: bytes, location: str = "", cookie: str = ""):
self.send_response(status)
headers = {"Location": location, "Set-Cookie": cookie,
"Content-Type": "text/html; charset=utf-8",
"Content-Length": str(len(body)), "Cache-Control": "no-store",
"Content-Security-Policy": "default-src 'none'"}
for name, value in headers.items():
if value:
self.send_header(name, value)
self.end_headers()
self.wfile.write(body)
def fail(self, example: Example, message: str):
body = f'<p>{html.escape(message)}</p><p><a href="/login">Sign in again</a></p>'
self.answer(400, page("Not signed in", body), cookie=example.cookie("", 0))
def home(self, example: Example, query: dict):
body = '<p><a href="/login">Sign in with Aurora Account</a></p>'
self.answer(200, page("Example", body))
def login(self, example: Example, query: dict):
now = time.time()
for state, (_, _, started) in list(example.pending.items()):
if now - started > PENDING_FOR:
example.pending.pop(state, None)
# Three fresh random values for this sign-in, kept on the server.
state, nonce, verifier = (secrets.token_urlsafe(32) for _ in range(3))
example.pending[state] = (verifier, nonce, now)
# PKCE: the challenge is the verifier's SHA-256, base64url without padding.
challenge = hashlib.sha256(verifier.encode("ascii")).digest()
code_challenge = base64.urlsafe_b64encode(challenge).rstrip(b"=").decode()
location = example.meta["authorization_endpoint"] + "?" + urlencode({
"response_type": "code",
"client_id": example.client_id,
"redirect_uri": example.redirect_uri,
"scope": SCOPE,
"state": state,
"nonce": nonce,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
})
# The state is tied to this browser by a cookie, checked on the way back.
cookie = example.cookie(state, PENDING_FOR)
self.answer(302, b"", location=location, cookie=cookie)
def callback(self, example: Example, query: dict):
# The state must be the one this browser was given, and still pending.
state = query.get("state", "")
kept = cookie_of(self.headers.get("Cookie", ""), "aurora_state")
pending = None
if kept and hmac.compare_digest(kept.encode(), state.encode()):
pending = example.pending.pop(state, None) # used once
if pending is None or time.time() - pending[2] > PENDING_FOR:
return self.fail(example, "This sign-in did not start here, or expired.")
# iss comes back on every answer, errors too (RFC 9207).
if query.get("iss") != example.issuer:
return self.fail(example, "The answer does not name the issuer.")
# access_denied: the person pressed Cancel (section 9).
if "error" in query:
return self.fail(example, f"Aurora Account answered {query['error']}.")
verifier, nonce, _ = pending
try:
# A new assertion for every request: each one is accepted once.
assertion = make_assertion(example.client_id, example.issuer,
example.private_pem)
# The code, the PKCE verifier and the assertion, form-encoded.
found = example.http.post(example.meta["token_endpoint"], timeout=10, data={
"grant_type": "authorization_code",
"code": query.get("code", ""),
"redirect_uri": example.redirect_uri,
"code_verifier": verifier,
"client_id": example.client_id,
"client_assertion_type": JWT_BEARER,
"client_assertion": assertion,
})
if found.status_code != 200:
return self.fail(example, f"The token endpoint answered {found.text}")
tokens = found.json()
# The ID token, checked before anything in it is believed (section 6).
claims = verify_id_token(tokens["id_token"], example.keys, example.issuer,
example.client_id, nonce)
# The access token goes in the Authorization header, never in the URL.
bearer = {"Authorization": f"Bearer {tokens['access_token']}"}
found = example.http.get(example.meta["userinfo_endpoint"], timeout=10,
headers=bearer)
found.raise_for_status()
userinfo = found.json()
# The same person as the ID token's.
if userinfo["sub"] != claims["sub"]:
return self.fail(example, "Userinfo names another person.")
except (requests.RequestException, jwt.PyJWTError, KeyError, ValueError) as e:
return self.fail(example, f"The sign-in failed: {e!r}")
# Here your service starts its own session, keyed on sub, never on the email
# (section 1).
you = html.escape(claims["sub"])
body = (f"<p>Aurora Account says you are <code>{you}</code>.</p>"
f"<h2>The ID token's claims</h2>{shown(claims)}"
f"<h2>Userinfo's answer</h2>{shown(userinfo)}"
'<p><a href="/login">Sign in again</a></p>')
self.answer(200, page("Signed in", body), cookie=example.cookie("", 0))
def main():
example = Example()
port = int(os.environ.get("PORT", "8443"))
# Loopback only: in production, a reverse proxy in front of it serves it to others.
server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
server.example = example
tls = bool(os.environ.get("TLS_CERT") or os.environ.get("TLS_KEY"))
if tls:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(setting("TLS_CERT"), setting("TLS_KEY"))
server.socket = context.wrap_socket(server.socket, server_side=True,
do_handshake_on_connect=False)
scheme = "https" if tls else "http"
print(f"Open {scheme}://localhost:{port}/ to sign in with {example.issuer}")
server.serve_forever()
if __name__ == "__main__":
main()Node.js 20 or later, with express and jose. It makes the
assertion with jose rather than an OpenID Connect library, so that its aud is the
issuer alone (section 5). Its files:
app.mjs,
package.json,
README.md and
LICENSE.
// Sign in with Aurora Account: a complete example in Node.js, 20 or later.
//
// A small web server that signs a person in as https://aurora-ws.com/developers
// describes: the authorization code flow with PKCE, a private_key_jwt client assertion
// for the token request, the ID token checked against Aurora Account's key set, and
// userinfo. It keeps the sign-ins under way in memory: an example to read and run, not
// a production server. README.md says how to run it. MIT licence: LICENSE.
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
import { readFileSync } from "node:fs";
import { createServer as createHttpServer } from "node:http";
import { createServer as createHttpsServer } from "node:https";
import { pathToFileURL } from "node:url";
import { SignJWT, createRemoteJWKSet, importPKCS8, jwtVerify } from "jose";
// Name your own service here: Cloudflare in front of Aurora Account turns away some
// libraries' default User-Agent (section 10).
const USER_AGENT =
"AuroraSignInExample/1.0 (Node.js; +https://aurora-ws.com/developers)";
const SCOPE = "openid email profile";
const PENDING_FOR = 10 * 60 * 1000; // ms a sign-in may take, from /login to /callback
const JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
// The client assertion that proves a token request is yours (section 5): signed with
// your private key, valid for a minute, and accepted once.
export async function makeAssertion(clientId, issuer, privateKey) {
const now = Math.floor(Date.now() / 1000);
const claims = {
iss: clientId, // you
sub: clientId, // you, again
aud: issuer, // the issuer itself, as one string
iat: now,
exp: now + 60, // a minute is plenty
jti: randomUUID(), // new every time: an assertion works once
};
return new SignJWT(claims)
.setProtectedHeader({ alg: "RS256", typ: "JWT" })
.sign(privateKey);
}
// The ID token's claims, once it is shown to be Aurora Account's, for you, and for this
// sign-in (section 6). Throws otherwise.
export async function verifyIdToken(idToken, keys, issuer, clientId, nonce) {
// The signature by the key set, RS256 only; then iss, aud and exp. The tolerance
// forgives a clock a little off Aurora Account's, as Aurora Account forgives yours.
const { payload: claims } = await jwtVerify(idToken, keys, {
algorithms: ["RS256"],
issuer,
audience: clientId,
requiredClaims: ["exp", "sub", "nonce"],
clockTolerance: 30,
});
// aud must be your client_id as a string, not a list that holds it.
if (typeof claims.aud !== "string")
throw new Error("The ID token's aud is not a string.");
// And the nonce this sign-in sent.
if (!same(claims.nonce, nonce))
throw new Error("The ID token's nonce is not the one sent.");
return claims;
}
function setting(name) {
if (!process.env[name]) throw new Error(`Set ${name}: README.md says how.`);
return process.env[name];
}
function same(a, b) {
const [x, y] = [Buffer.from(String(a)), Buffer.from(String(b))];
return x.length === y.length && timingSafeEqual(x, y);
}
// One cookie from the Cookie header, read by hand: on localhost every dev server's
// cookies arrive here, and none of them may get in the way.
function cookieOf(req, name) {
for (const part of (req.headers.cookie ?? "").split(";")) {
const at = part.indexOf("=");
if (at !== -1 && part.slice(0, at).trim() === name)
return part.slice(at + 1).trim();
}
return "";
}
const escape = (text) =>
String(text).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
const shown = (value) => `<pre>${escape(JSON.stringify(value, null, 2))}</pre>`;
const page = (title, body) =>
`<!doctype html><html lang="en"><meta charset="utf-8">` +
`<meta name="viewport" content="width=device-width, initial-scale=1">` +
`<title>${title}</title><h1>${title}</h1>${body}`;
async function main() {
const { default: express } = await import("express"); // only the server needs it
const issuer = process.env.AURORA_ISSUER || "https://account.aurora-ws.com/o";
const clientId = setting("CLIENT_ID");
const redirectUri = setting("REDIRECT_URI");
// A key that cannot sign fails now, not at the first sign-in.
const pem = readFileSync(setting("PRIVATE_KEY_PATH"), "utf8");
const privateKey = await importPKCS8(pem, "RS256").catch((error) => {
throw new Error(
`PRIVATE_KEY_PATH holds no RSA private key that signs: ${error.message}`,
);
});
const headers = { "User-Agent": USER_AGENT };
// The endpoints come from the discovery document, which names the issuer.
const found = await fetch(`${issuer}/.well-known/openid-configuration`, {
headers,
signal: AbortSignal.timeout(10_000),
});
if (!found.ok) throw new Error(`The discovery document answered ${found.status}.`);
const meta = await found.json();
// It must name the issuer you configured, exactly.
if (meta.issuer !== issuer)
throw new Error(`The discovery document names ${meta.issuer}.`);
// The key set: cached, and fetched again for a token whose key it does not hold
// (section 6).
const keys = createRemoteJWKSet(new URL(meta.jwks_uri), {
headers,
timeoutDuration: 10_000,
});
const pending = new Map(); // state -> { verifier, nonce, started }
const cookie = {
httpOnly: true,
sameSite: "lax",
secure: redirectUri.startsWith("https://"),
};
const app = express();
app.disable("x-powered-by");
app.use((req, res, next) => {
res.set({
"Cache-Control": "no-store",
"Content-Security-Policy": "default-src 'none'",
});
next();
});
const fail = (res, message) => {
const body = `<p>${escape(message)}</p><p><a href="/login">Sign in again</a></p>`;
res
.status(400)
.clearCookie("aurora_state", cookie)
.send(page("Not signed in", body));
};
app.get("/", (req, res) => {
res.send(
page("Example", `<p><a href="/login">Sign in with Aurora Account</a></p>`),
);
});
app.get("/login", (req, res) => {
const now = Date.now();
for (const [state, { started }] of pending)
if (now - started > PENDING_FOR) pending.delete(state);
// Three fresh random values for this sign-in, kept on the server.
const [state, nonce, verifier] = [1, 2, 3].map(() =>
randomBytes(32).toString("base64url"),
);
pending.set(state, { verifier, nonce, started: now });
// PKCE: the challenge is the verifier's SHA-256, base64url without padding.
const codeChallenge = createHash("sha256").update(verifier).digest("base64url");
const url = new URL(meta.authorization_endpoint);
url.search = new URLSearchParams({
response_type: "code",
client_id: clientId,
redirect_uri: redirectUri,
scope: SCOPE,
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: "S256",
}).toString();
// The state is tied to this browser by a cookie, checked on the way back.
res
.cookie("aurora_state", state, { ...cookie, maxAge: PENDING_FOR })
.redirect(url.href);
});
app.get("/callback", async (req, res) => {
// The state must be the one this browser was given, and still pending.
const query = new URL(req.originalUrl, "http://localhost").searchParams;
const state = query.get("state") ?? "";
const started = same(cookieOf(req, "aurora_state"), state)
? pending.get(state)
: undefined;
if (started) pending.delete(state); // used once
if (!started || Date.now() - started.started > PENDING_FOR) {
return fail(res, "This sign-in did not start here, or expired.");
}
// iss comes back on every answer, errors too (RFC 9207).
if (query.get("iss") !== issuer)
return fail(res, "The answer does not name the issuer.");
// access_denied: the person pressed Cancel (section 9).
if (query.has("error"))
return fail(res, `Aurora Account answered ${query.get("error")}.`);
try {
// The code, the PKCE verifier and a new assertion, form-encoded. Each assertion
// is accepted once, so every request makes its own.
const token = await fetch(meta.token_endpoint, {
method: "POST",
headers,
signal: AbortSignal.timeout(10_000),
body: new URLSearchParams({
grant_type: "authorization_code",
code: query.get("code") ?? "",
redirect_uri: redirectUri,
code_verifier: started.verifier,
client_id: clientId,
client_assertion_type: JWT_BEARER,
client_assertion: await makeAssertion(clientId, issuer, privateKey),
}),
});
if (!token.ok)
return fail(res, `The token endpoint answered ${await token.text()}`);
const tokens = await token.json();
// The ID token, checked before anything in it is believed (section 6).
const claims = await verifyIdToken(
tokens.id_token,
keys,
issuer,
clientId,
started.nonce,
);
// The access token goes in the Authorization header, never in the URL.
const info = await fetch(meta.userinfo_endpoint, {
headers: { ...headers, Authorization: `Bearer ${tokens.access_token}` },
signal: AbortSignal.timeout(10_000),
});
if (!info.ok) return fail(res, `Userinfo answered ${await info.text()}`);
const userinfo = await info.json();
// The same person as the ID token's.
if (userinfo.sub !== claims.sub)
return fail(res, "Userinfo names another person.");
// Here your service starts its own session, keyed on sub, never on the email
// (section 1).
const body =
`<p>Aurora Account says you are <code>${escape(claims.sub)}</code>.</p>` +
`<h2>The ID token's claims</h2>${shown(claims)}` +
`<h2>Userinfo's answer</h2>${shown(userinfo)}` +
`<p><a href="/login">Sign in again</a></p>`;
res.clearCookie("aurora_state", cookie).send(page("Signed in", body));
} catch (problem) {
fail(res, `The sign-in failed: ${problem}`);
}
});
const port = Number(process.env.PORT || 8443);
const tls = Boolean(process.env.TLS_CERT || process.env.TLS_KEY);
const certificate = () => ({
cert: readFileSync(setting("TLS_CERT")),
key: readFileSync(setting("TLS_KEY")),
});
const server = tls ? createHttpsServer(certificate(), app) : createHttpServer(app);
server.on("error", (error) => {
console.error(`Cannot serve on port ${port}: ${error.message}`);
process.exit(1);
});
// Loopback only: in production, a reverse proxy in front of it serves it to others.
server.listen(port, "127.0.0.1", () => {
console.log(
`Open ${tls ? "https" : "http"}://localhost:${port}/ to sign in with ${issuer}`,
);
});
}
// Started as a program (`node app.mjs`), it serves; imported, it lends makeAssertion.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((problem) => {
console.error(problem.message);
process.exit(1);
});
}1. What you get
Sign-in only: Aurora Account tells your service who the person is. No other Aurora interface accepts your access token; userinfo is the only endpoint that does.
| Claim | Scope | What it is |
|---|---|---|
sub | openid | An identifier that means this person to your service alone. Another service gets a different one for the same person. It is an opaque string; store it as text. It stays the same for as long as the account exists: when the person changes their email address, and when they disconnect your service and connect it again. |
email | email | The person’s address. It can change;
sub does not. |
email_ | email | Always true: Aurora
Account confirms every address, by a one-time code or through Google. |
name | profile | The name on the person’s account, as they wrote it. It can be empty. |
Important. Key your users on sub, never on the email address.
Nothing else: not how or when the person signed in, not their language, and not the account’s own id.
No refresh tokens and no logout notices. We do not tell you when the person signs out of Aurora Account, and you cannot sign them out of it. Start your own session when they sign in, and keep it as your service sees fit.
Signing in without a screen
prompt=none signs the person in without showing anything when it can. When it cannot, it comes
back with login_required (nobody is signed in to Aurora Account in that browser) or
consent_required (the person has not agreed to what you ask).
What the person sees
The first time, Aurora Account’s sign-in (a person with no account creates one on the way), then a consent screen with your name and icon, listing what you will receive, with Allow and Cancel. After Allow, later sign-ins skip the consent screen, unless you ask for a scope they have not agreed to. The screens speak English, Russian and Ukrainian, as the person chooses.
2. What to send us
Write to us at yafebest@gmail.com with:
| What | Requirements |
|---|---|
| The name | What people will see on the consent screen: at most 40 characters, with no “Aurora” anywhere in it. |
| Your redirect URIs | Each matched exactly, with no wildcards. https only, and that includes
local development: https://localhost:8443/callback works, http://localhost/… does
not. |
| Your website | Required, with your privacy policy and terms of use if you have them. All https. The consent screen links your privacy policy and terms; it links your website when you have neither. |
| An icon | PNG, JPEG or WebP, square, at least 128 px on a side and at most 1 MB. SVG and GIF are not accepted, nor anything over 4096 px on a side. We redraw it as a 256 × 256 PNG, whole and centred, never cropped. A transparent background is fine: we choose a light or dark backdrop to suit the artwork. |
| Your public key | RSA, at least 2048 bits, in PEM
(-----BEGIN PUBLIC KEY-----). |
Important. The private key stays on your server. Never send it to anyone, us included.
Making the key pair
One way, with OpenSSL:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out aurora-private.pem
openssl pkey -in aurora-private.pem -pubout -out aurora-public.pemWe send you back your client_id. It is not a secret, and there is no secret to go with it.
3. Endpoints
Take them from the discovery document, at
https://account.aurora-ws.com/o/.well-known/openid-configuration. Today it names these:
| Field | Value |
|---|---|
issuer | https://account.aurora-ws.com/o |
authorization_ | https://account.aurora-ws.com/o/authorize/ |
token_ | https://account.aurora-ws.com/o/token/ |
userinfo_ | https://account.aurora-ws.com/o/userinfo/ |
jwks_ | https://account.aurora-ws.com/o/.well-known/jwks.json |
The issuer has no trailing slash; the authorization, token and userinfo endpoints have one. The document also
names end_session_endpoint, which is for Aurora’s own products and does nothing for yours
(section 8).
Reading the discovery document
Check that its issuer is the one you configured, and keep the key set it names.
# The endpoints come from the discovery document, which names the issuer.
found = self.http.get(f"{self.issuer}/.well-known/openid-configuration",
timeout=10)
found.raise_for_status()
self.meta = found.json()
# It must name the issuer you configured, exactly.
if self.meta["issuer"] != self.issuer:
sys.exit(f"The discovery document names the issuer {self.meta['issuer']}.")
# The key set: cached, and fetched again for a token whose key it does not
# hold (section 6).
self.keys = jwt.PyJWKClient(self.meta["jwks_uri"],
headers={"User-Agent": USER_AGENT})// The endpoints come from the discovery document, which names the issuer.
const found = await fetch(`${issuer}/.well-known/openid-configuration`, {
headers,
signal: AbortSignal.timeout(10_000),
});
if (!found.ok) throw new Error(`The discovery document answered ${found.status}.`);
const meta = await found.json();
// It must name the issuer you configured, exactly.
if (meta.issuer !== issuer)
throw new Error(`The discovery document names ${meta.issuer}.`);
// The key set: cached, and fetched again for a token whose key it does not hold
// (section 6).
const keys = createRemoteJWKSet(new URL(meta.jwks_uri), {
headers,
timeoutDuration: 10_000,
});curl https://account.aurora-ws.com/o/.well-known/openid-configuration \
--user-agent "YourService/1.0 (+https://service.example)"4. The authorization request
GET https://account.aurora-ws.com/o/authorize/
Send the person’s browser to the authorization endpoint. The parameters, on several lines here for reading:
# Three fresh random values for this sign-in, kept on the server.
state, nonce, verifier = (secrets.token_urlsafe(32) for _ in range(3))
example.pending[state] = (verifier, nonce, now)
# PKCE: the challenge is the verifier's SHA-256, base64url without padding.
challenge = hashlib.sha256(verifier.encode("ascii")).digest()
code_challenge = base64.urlsafe_b64encode(challenge).rstrip(b"=").decode()
location = example.meta["authorization_endpoint"] + "?" + urlencode({
"response_type": "code",
"client_id": example.client_id,
"redirect_uri": example.redirect_uri,
"scope": SCOPE,
"state": state,
"nonce": nonce,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
})
# The state is tied to this browser by a cookie, checked on the way back.
cookie = example.cookie(state, PENDING_FOR)
self.answer(302, b"", location=location, cookie=cookie)// Three fresh random values for this sign-in, kept on the server.
const [state, nonce, verifier] = [1, 2, 3].map(() =>
randomBytes(32).toString("base64url"),
);
pending.set(state, { verifier, nonce, started: now });
// PKCE: the challenge is the verifier's SHA-256, base64url without padding.
const codeChallenge = createHash("sha256").update(verifier).digest("base64url");
const url = new URL(meta.authorization_endpoint);
url.search = new URLSearchParams({
response_type: "code",
client_id: clientId,
redirect_uri: redirectUri,
scope: SCOPE,
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: "S256",
}).toString();
// The state is tied to this browser by a cookie, checked on the way back.
res
.cookie("aurora_state", state, { ...cookie, maxAge: PENDING_FOR })
.redirect(url.href);https://account.aurora-ws.com/o/authorize/
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https%3A%2F%2Fservice.example%2Fauth%2Faurora%2Fcallback
&scope=openid%20email%20profile
&state=RANDOM_STATE
&nonce=RANDOM_NONCE
&code_challenge=BASE64URL_OF_SHA256_OF_THE_VERIFIER
&code_challenge_method=S256Parameters
| Parameter | Rule |
|---|---|
response_ | code, and nothing else: the implicit and hybrid
flows are refused. |
scope | openid is required; add email and
profile for what you need. A request without openid, or with a scope beyond these
three, is refused with invalid_scope, before the consent screen. |
code_, code_ | PKCE with
S256 is required. Make a fresh code_verifier for each request (43 to 128
characters of letters, digits, -, ., _ and ~), keep it on
your server, and send code_challenge = BASE64URL(SHA-256(verifier)), without padding, with
code_challenge_method=S256. Without the method, or with plain, the request is
refused with invalid_request. |
state, nonce | Fresh random values for each request, kept on your
side. state comes back on the redirect; nonce comes back in the ID
token. |
prompt | Optional: none (section 1), or consent to show
the consent screen even when the person has already agreed. |
acr_, max_ | Ignored. |
Back at your redirect URI
The browser comes back to your redirect URI:
https://service.example/auth/aurora/callback?code=…&state=RANDOM_STATE&iss=https%3A%2F%2Faccount.aurora-ws.com%2Fo- Check
stateagainst the one you kept for this browser. - Check
iss: it ishttps://account.aurora-ws.com/oexactly, on every answer that comes back to your redirect URI, errors included (RFC 9207). Refuse an answer without it. - An answer with
errorinstead ofcodecarriesstateandisstoo.access_deniedmeans the person pressed Cancel; the others are in section 9.
# The state must be the one this browser was given, and still pending.
state = query.get("state", "")
kept = cookie_of(self.headers.get("Cookie", ""), "aurora_state")
pending = None
if kept and hmac.compare_digest(kept.encode(), state.encode()):
pending = example.pending.pop(state, None) # used once
if pending is None or time.time() - pending[2] > PENDING_FOR:
return self.fail(example, "This sign-in did not start here, or expired.")
# iss comes back on every answer, errors too (RFC 9207).
if query.get("iss") != example.issuer:
return self.fail(example, "The answer does not name the issuer.")
# access_denied: the person pressed Cancel (section 9).
if "error" in query:
return self.fail(example, f"Aurora Account answered {query['error']}.")// The state must be the one this browser was given, and still pending.
const query = new URL(req.originalUrl, "http://localhost").searchParams;
const state = query.get("state") ?? "";
const started = same(cookieOf(req, "aurora_state"), state)
? pending.get(state)
: undefined;
if (started) pending.delete(state); // used once
if (!started || Date.now() - started.started > PENDING_FOR) {
return fail(res, "This sign-in did not start here, or expired.");
}
// iss comes back on every answer, errors too (RFC 9207).
if (query.get("iss") !== issuer)
return fail(res, "The answer does not name the issuer.");
// access_denied: the person pressed Cancel (section 9).
if (query.has("error"))
return fail(res, `Aurora Account answered ${query.get("error")}.`);Important. The code works once, for 60 seconds: exchange it at once.
5. The token request
POST https://account.aurora-ws.com/o/token/
Your server exchanges the code with a POST to the token endpoint, form-encoded. It proves who it
is with a client assertion: a JWT it signs with your private key (RFC 7523, the
private_key_jwt method of OpenID Connect Core §9). Most OpenID Connect libraries make it for you
once configured for private_key_jwt; this section is what they must produce.
The assertion’s header
{
"alg": "RS256",
"typ": "JWT"
}alg:RS256orPS256. Anything else is refused,noneandHS256included.typis optional, andkidis ignored: we hold one key for you.
The assertion’s claims
{
"iss": "YOUR_CLIENT_ID",
"sub": "YOUR_CLIENT_ID",
"aud": "https://account.aurora-ws.com/o",
"iat": 1793016000,
"exp": 1793016060,
"jti": "0b6a4f3e-9d2c-4f61-8a57-2f1e9c3d7b10"
}| Claim | Rule |
|---|---|
iss, sub | Required. Both are your client_id. |
aud | Required. The issuer, https://account.aurora-ws.com/o, exactly:
no trailing slash. A single string, or an array holding that one value. The token endpoint’s URL,
https://account.aurora-ws.com/o/token/, is also accepted, but send the issuer. An array with
more than one value is refused: if your library sends one, set its audience to the issuer. |
exp | Required, in seconds since 1970. At most 300 seconds (5 minutes) after now
by our clock, with no leeway on that bound, and not more than 30 seconds in the past. We suggest
iat + 60. |
iat, nbf | Optional. When present, not more than 30 seconds in the future by our clock. |
jti | Required. A new unique value every time, such as a random UUID. An
assertion is accepted once: a second request with the same jti is refused. |
Times are judged by our clock, so keep your server’s clock synchronised.
Making the assertion
def make_assertion(client_id: str, issuer: str, private_pem: bytes) -> str:
"""The client assertion that proves a token request is yours (section 5): signed
with your private key, valid for a minute, and accepted once."""
now = int(time.time())
claims = {
"iss": client_id, # you
"sub": client_id, # you, again
"aud": issuer, # the issuer itself, as one string
"iat": now,
"exp": now + 60, # a minute is plenty
"jti": str(uuid.uuid4()), # new every time: an assertion works once
}
return jwt.encode(claims, private_pem, algorithm="RS256")// The client assertion that proves a token request is yours (section 5): signed with
// your private key, valid for a minute, and accepted once.
export async function makeAssertion(clientId, issuer, privateKey) {
const now = Math.floor(Date.now() / 1000);
const claims = {
iss: clientId, // you
sub: clientId, // you, again
aud: issuer, // the issuer itself, as one string
iat: now,
exp: now + 60, // a minute is plenty
jti: randomUUID(), // new every time: an assertion works once
};
return new SignJWT(claims)
.setProtectedHeader({ alg: "RS256", typ: "JWT" })
.sign(privateKey);
}The request
# A new assertion for every request: each one is accepted once.
assertion = make_assertion(example.client_id, example.issuer,
example.private_pem)
# The code, the PKCE verifier and the assertion, form-encoded.
found = example.http.post(example.meta["token_endpoint"], timeout=10, data={
"grant_type": "authorization_code",
"code": query.get("code", ""),
"redirect_uri": example.redirect_uri,
"code_verifier": verifier,
"client_id": example.client_id,
"client_assertion_type": JWT_BEARER,
"client_assertion": assertion,
})
if found.status_code != 200:
return self.fail(example, f"The token endpoint answered {found.text}")
tokens = found.json()// The code, the PKCE verifier and a new assertion, form-encoded. Each assertion
// is accepted once, so every request makes its own.
const token = await fetch(meta.token_endpoint, {
method: "POST",
headers,
signal: AbortSignal.timeout(10_000),
body: new URLSearchParams({
grant_type: "authorization_code",
code: query.get("code") ?? "",
redirect_uri: redirectUri,
code_verifier: started.verifier,
client_id: clientId,
client_assertion_type: JWT_BEARER,
client_assertion: await makeAssertion(clientId, issuer, privateKey),
}),
});
if (!token.ok)
return fail(res, `The token endpoint answered ${await token.text()}`);
const tokens = await token.json();curl https://account.aurora-ws.com/o/token/ \
--user-agent "YourService/1.0 (+https://service.example)" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=$CODE" \
--data-urlencode "redirect_uri=https://service.example/auth/aurora/callback" \
--data-urlencode "code_verifier=$CODE_VERIFIER" \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
--data-urlencode "client_assertion=$CLIENT_ASSERTION"redirect_uriis the one you sent in the authorization request, andcode_verifierthe verifier you made for it.client_idmay be left out. When it is sent, it must be the assertion’ssub.- The assertion is the only proof accepted. Send no
client_secretand no HTTP BasicAuthorizationheader: either one beside the assertion is refused.
The answer
With Cache-Control: no-store:
{
"access_token": "…",
"expires_in": 3600,
"token_type": "Bearer",
"scope": "openid email profile",
"refresh_token": "…",
"id_token": "eyJ…"
}The access token lasts an hour, for userinfo.
Note. The refresh_token cannot be used: the token endpoint refuses the refresh
grant, with unauthorized_client. Ignore it.
6. Validating the ID token
The ID token is a JWT signed by Aurora Account. Its header and claims look like this:
{
"alg": "RS256",
"kid": "…",
"typ": "JWT"
}
{
"iss": "https://account.aurora-ws.com/o",
"aud": "YOUR_CLIENT_ID",
"sub": "0XGax5SPG8UMfok8Xpid5Q",
"iat": 1793016001,
"exp": 1793016301,
"jti": "…",
"nonce": "RANDOM_NONCE",
"at_hash": "…",
"email": "person@example.com",
"email_verified": true,
"name": "Alex Doe"
}email and email_verified are there with the email scope, and
name with profile. Nothing else is. Before you trust it:
- The signature.
RS256, by the key injwks_uriwhosekidis the header’s. AcceptRS256only. Cache the key set; when a token names akidyou do not have, fetch it again, because keys are rotated. A retired key stays published for a while after. issishttps://account.aurora-ws.com/o.audis yourclient_id, as a string.expis in the future. An ID token lives 5 minutes fromiat.nonceis the one you sent.
def verify_id_token(id_token: str, keys: jwt.PyJWKClient, issuer: str, client_id: str,
nonce: str) -> dict:
"""The ID token's claims, once it is shown to be Aurora Account's, for you, and for
this sign-in (section 6). Raises jwt.InvalidTokenError otherwise."""
# The signature, by the key the token's header names, RS256 only.
key = keys.get_signing_key_from_jwt(id_token)
# Then iss, aud and exp. strict_aud refuses an aud that is a list, and the leeway
# forgives a clock a little off Aurora Account's, as Aurora Account forgives yours.
claims = jwt.decode(id_token, key.key, algorithms=["RS256"], issuer=issuer,
audience=client_id, leeway=30,
options={"require": ["iss", "aud", "exp", "sub", "nonce"],
"strict_aud": True})
# And the nonce this sign-in sent.
if not hmac.compare_digest(str(claims["nonce"]).encode(), nonce.encode()):
raise jwt.InvalidTokenError("The ID token's nonce is not the one sent.")
return claims// The ID token's claims, once it is shown to be Aurora Account's, for you, and for this
// sign-in (section 6). Throws otherwise.
export async function verifyIdToken(idToken, keys, issuer, clientId, nonce) {
// The signature by the key set, RS256 only; then iss, aud and exp. The tolerance
// forgives a clock a little off Aurora Account's, as Aurora Account forgives yours.
const { payload: claims } = await jwtVerify(idToken, keys, {
algorithms: ["RS256"],
issuer,
audience: clientId,
requiredClaims: ["exp", "sub", "nonce"],
clockTolerance: 30,
});
// aud must be your client_id as a string, not a list that holds it.
if (typeof claims.aud !== "string")
throw new Error("The ID token's aud is not a string.");
// And the nonce this sign-in sent.
if (!same(claims.nonce, nonce))
throw new Error("The ID token's nonce is not the one sent.");
return claims;
}Then the person is sub.
7. Userinfo
GET https://account.aurora-ws.com/o/userinfo/
# The access token goes in the Authorization header, never in the URL.
bearer = {"Authorization": f"Bearer {tokens['access_token']}"}
found = example.http.get(example.meta["userinfo_endpoint"], timeout=10,
headers=bearer)
found.raise_for_status()
userinfo = found.json()
# The same person as the ID token's.
if userinfo["sub"] != claims["sub"]:
return self.fail(example, "Userinfo names another person.")// The access token goes in the Authorization header, never in the URL.
const info = await fetch(meta.userinfo_endpoint, {
headers: { ...headers, Authorization: `Bearer ${tokens.access_token}` },
signal: AbortSignal.timeout(10_000),
});
if (!info.ok) return fail(res, `Userinfo answered ${await info.text()}`);
const userinfo = await info.json();
// The same person as the ID token's.
if (userinfo.sub !== claims.sub)
return fail(res, "Userinfo names another person.");curl https://account.aurora-ws.com/o/userinfo/ \
--user-agent "YourService/1.0 (+https://service.example)" \
--header "Authorization: Bearer $ACCESS_TOKEN"{
"sub": "0XGax5SPG8UMfok8Xpid5Q",
"email": "person@example.com",
"email_verified": true,
"name": "Alex Doe"
}- The same
subas the ID token, and the same claims for the same scopes. - The access token goes in the
Authorizationheader only. In the URL, it is refused. - It stops working before its hour is up when the person disconnects your service, signs out of every device or reports a sign-in as not theirs, and when their account is blocked.
8. Sign-out and disconnect
- Your sign-out is yours. End your own session and nothing more. Do not send the browser to
end_session_endpoint: for your service it ends nothing, and the person lands on Aurora Account’s home instead of back with you. - We send no logout notices. You are not told when the person signs out of Aurora Account, when their account is blocked, or when it is deleted.
- The person can disconnect your service at any time, in their account under Security,
Third-party services. Its tokens stop working at once, and its next sign-in shows the consent screen again. If
they agree,
subis the same as before. - What you have already received stays with you, under your own privacy policy.
9. Errors
Back at your redirect URI
As error in the query, with state and iss:
| Error | When |
|---|---|
access_ | The person pressed Cancel on the consent screen. |
login_ | prompt=none, and nobody is signed in to Aurora Account in that browser. |
consent_ | prompt=none, and the person has not agreed to every scope asked for. |
invalid_ | No openid in scope, or a scope other than openid, email and profile. |
invalid_ | The request is malformed: for example, no response_type, no PKCE challenge, or a code_challenge_method other than S256, a missing one included. |
unauthorized_ | A response_type of the implicit or hybrid flows, such as token or code id_token. |
unsupported_ | A response_type the server does not know. |
An unknown client_id, or a redirect_uri that is not registered exactly, is never
sent back to you: the browser gets an error answer from Aurora Account instead. So does
prompt=create, which is not supported.
From the token endpoint
As JSON, such as {"error": "invalid_client"}:
| Error | Status | When |
|---|---|---|
invalid_ | 401 | The assertion failed any of the rules in section
5, a secret or an HTTP Basic Authorization header came with it, client_id names
another client, or we do not hold your key yet. The answer is the same whatever the reason. Write to us with
the time of the request and we will tell you which rule it was. |
invalid_ | 400 | A parameter is missing, such as
code or code_verifier; or redirect_uri is not the one sent in the
authorization request, answered with "error_description": "Mismatching redirect URI." |
invalid_ | 400 | The code is unknown, used, older than 60 seconds
or issued to another client; code_verifier does not match its challenge; or the person signed
out of Aurora Account in that browser before the exchange. |
unauthorized_ | 400 | A grant_type the server knows
but does not give you, such as refresh_token or client_credentials. |
unsupported_ | 400 | A grant_type the
server does not know. |
From userinfo
| Error | Status | When |
|---|---|---|
invalid_ | 401 | The access token is expired, revoked or unknown,
or was issued without the openid scope. |
invalid_ | 400 | The access token was sent in the URL. |
10. Operational
- Send a descriptive
User-Agenton every request your server makes to us: discovery, the key set, the token endpoint and userinfo. For example,YourService/1.0 (+https://service.example). Cloudflare’s bot protection in front ofaccount.aurora-ws.comblocks some libraries’ defaults, such as Python’sPython-urllib. The answer is then an error page from Cloudflare, not JSON from us. - Keep the private key on your server. Never put it in a browser, a mobile app or a repository. Whoever holds it can sign in as your service.
- Make a new assertion for every token request, a retry included: a
jtiis accepted once. - Keep your clock synchronised, by NTP or the like. An assertion is judged by our clock, with 30 seconds of leeway.
11. Rotating the key or the icon
- A new key: send us the new public key. We hold one key for you, so the switch is immediate: once we save the new one, only assertions it signs are accepted, and the old key is refused. Agree a time with us, and switch your server to the new key at that moment.
- A new icon: send us the new file, with the same requirements as in section 2. It shows from the next page that loads it.
For anything else, write to us at yafebest@gmail.com.