Security

OAuth PKCE

Sign in with Google, GitHub or Microsoft made safe: PKCE verifier and challenge, state, the authorization link and the token request.

Version 1.0.0 · 5 functions · 4 crates

#auth#oauth#sso#sign-in

Functions

What use oauth_pkce.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use pkce_challenge, pkce_pair from oauth_pkce (Quillo 0.6).

pkce_challenge(verifier: str) → stror an error

The SHA-256 challenge of a verifier, base64url (the S256 method). The verifier is 43 to 128 characters of A-Z a-z 0-9 - . _ ~, as RFC 7636 says; anything else is an error.

pkce_pair() → map

A new { verifier, challenge, method }: keep verifier in the session, send challenge (method "S256") in the link.

oauth_state() → str

A random state (32 bytes, base64url) to keep in the session and compare when the provider sends the visitor back.

oauth_authorize_url(provider_name: str, client_id: str, redirect_uri: str, scopes: list, state: str, challenge: str) → stror an error

The link that sends the visitor to provider ("google", "github", "microsoft", or "microsoft:<tenant>") to sign in. scopes empty takes the provider's usual ones for a sign-in (an email and a name). state and challenge come from oauth_state and pkce_pair.

oauth_token_request(provider_name: str, client_id: str, client_secret: str, code: str, redirect_uri: str, verifier: str) → anyor an error

The request that trades the code the provider sent back for tokens: { url, body, headers }, to send with http.post(r.url, r.body, r.headers). body is form-encoded and carries the verifier.

Get it

$ quillo install oauth_pkce               # oauth_pkce.qwer into this folder$ quillo install oauth_pkce --example     # and its example, oauth_pkce.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/oauth_pkce.qwer$ curl -O https://quillolang.org/plugins/files/oauth_pkce.qwe

Then, in your program, all of it or only what you call:

main.qwe
use oauth_pkce.qweruse pkce_challenge, pkce_pair from oauth_pkce      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
sha2 = "0.10"base64 = "0.22"rand = "0.8"url = "2"

Example

oauth_pkce.qwe
// "Sign in with GitHub" with PKCE and a state value, as OAuth 2.1 asks.////   quillo run oauth_pkce.qwe      then open http://localhost:8080/login/githubuse "oauth_pkce.qwer"let CLIENT_ID = env("GITHUB_CLIENT_ID") ?? "Iv1.quillo-example"let CLIENT_SECRET = env("GITHUB_CLIENT_SECRET") ?? ""let CALLBACK = "http://localhost:8080/auth/github"// Step 1: keep the verifier and state, send the visitor to GitHub.route GET /login/github {    let pkce = pkce_pair()    let state = oauth_state()    set_session({ verifier: pkce.verifier, state }, { max_age: "10m" })    let link = oauth_authorize_url("github", CLIENT_ID, CALLBACK, [], state, pkce.challenge)?    respond(200, { go_to: link }, {})}// Step 2: GitHub sends the visitor back with a code; the state must be ours.route GET /auth/github ? { code: str, state: str } {    let s = session()    if s.state == null || !crypto.equal(s.state, state) { reply 400 "This sign-in was not started here." }    let r = oauth_token_request("github", CLIENT_ID, CLIENT_SECRET, code, CALLBACK, s.verifier)?    let answer = http.post(r.url, r.body, r.headers, { public_only: true })?    set_session({ github_token: json.parse(answer.body)?.access_token })    redirect("/")}// What the challenge is: RFC 7636's own example.route GET /rfc = { challenge: pkce_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")?, google: oauth_authorize_url("google", "1234.apps.googleusercontent.com", CALLBACK, ["openid", "email"], "xyz", "abc")? }
$ quillo run oauth_pkce.qwe$ curl 'localhost:8080/rfc'$ curl 'localhost:8080/login/github'

Source

oauth_pkce.qwer
//! "Sign in with Google, GitHub or Microsoft" made safe: PKCE verifier and challenge, the state value, the authorization link, and the token request to send after the redirect.//! version: 1.0.0//!//! [dependencies]//! sha2 = "0.10"//! base64 = "0.22"//! rand = "0.8"//! url = "2"use base64::engine::general_purpose::URL_SAFE_NO_PAD;use base64::Engine;use rand::RngCore;use serde_json::{json, Value};use sha2::{Digest, Sha256};use std::collections::HashMap;fn random(n: usize) -> String {    let mut b = vec![0u8; n];    rand::rngs::OsRng.fill_bytes(&mut b);    URL_SAFE_NO_PAD.encode(b)}struct Provider {    authorize: String,    token: String,    scopes: &'static str,}fn provider(name: &str) -> Result<Provider, String> {    let p = |a: &str, t: &str, s: &'static str| Provider { authorize: a.to_string(), token: t.to_string(), scopes: s };    Ok(match name.to_lowercase().as_str() {        "google" => p("https://accounts.google.com/o/oauth2/v2/auth", "https://oauth2.googleapis.com/token", "openid email profile"),        "github" => p("https://github.com/login/oauth/authorize", "https://github.com/login/oauth/access_token", "read:user user:email"),        "microsoft" => p("https://login.microsoftonline.com/common/oauth2/v2.0/authorize", "https://login.microsoftonline.com/common/oauth2/v2.0/token", "openid email profile"),        other => match other.strip_prefix("microsoft:") {            // A single tenant: "microsoft:contoso.onmicrosoft.com" or its id.            Some(tenant) if !tenant.is_empty() && tenant.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.') => p(                &format!("https://login.microsoftonline.com/{}/oauth2/v2.0/authorize", tenant),                &format!("https://login.microsoftonline.com/{}/oauth2/v2.0/token", tenant),                "openid email profile",            ),            _ => return Err(format!("unknown provider `{}`: google, github, microsoft or microsoft:<tenant>", name)),        },    })}/// The SHA-256 challenge of a verifier, base64url (the `S256` method). The verifier is 43 to 128 characters of/// `A-Z a-z 0-9 - . _ ~`, as RFC 7636 says; anything else is an error.pub fn pkce_challenge(verifier: &str) -> Result<String, String> {    if !(43..=128).contains(&verifier.len()) || !verifier.chars().all(|c| c.is_ascii_alphanumeric() || "-._~".contains(c)) {        return Err("a PKCE verifier is 43 to 128 characters of A-Z a-z 0-9 - . _ ~".into());    }    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())))}/// A new `{ verifier, challenge, method }`: keep `verifier` in the session, send `challenge` (method "S256") in the link.pub fn pkce_pair() -> HashMap<String, String> {    let verifier = random(48);    let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));    HashMap::from([("verifier".into(), verifier), ("challenge".into(), challenge), ("method".into(), "S256".into())])}/// A random `state` (32 bytes, base64url) to keep in the session and compare when the provider sends the visitor back.pub fn oauth_state() -> String {    random(32)}/// The link that sends the visitor to `provider` ("google", "github", "microsoft", or "microsoft:<tenant>") to sign in./// `scopes` empty takes the provider's usual ones for a sign-in (an email and a name). `state` and `challenge` come from/// oauth_state and pkce_pair.pub fn oauth_authorize_url(provider_name: &str, client_id: &str, redirect_uri: &str, scopes: Vec<String>, state: &str, challenge: &str) -> Result<String, String> {    let p = provider(provider_name)?;    url::Url::parse(redirect_uri).map_err(|e| format!("the redirect URI `{}` is not a URL: {}", redirect_uri, e))?;    let scope = if scopes.is_empty() { p.scopes.to_string() } else { scopes.join(" ") };    let u = url::Url::parse_with_params(&p.authorize, &[        ("response_type", "code"),        ("client_id", client_id),        ("redirect_uri", redirect_uri),        ("scope", scope.as_str()),        ("state", state),        ("code_challenge", challenge),        ("code_challenge_method", "S256"),    ]).map_err(|e| e.to_string())?;    Ok(u.to_string())}/// The request that trades the `code` the provider sent back for tokens: `{ url, body, headers }`, to send with/// `http.post(r.url, r.body, r.headers)`. `body` is form-encoded and carries the `verifier`.pub fn oauth_token_request(provider_name: &str, client_id: &str, client_secret: &str, code: &str, redirect_uri: &str, verifier: &str) -> Result<Value, String> {    let p = provider(provider_name)?;    let mut form = url::form_urlencoded::Serializer::new(String::new());    form.append_pair("grant_type", "authorization_code").append_pair("code", code).append_pair("redirect_uri", redirect_uri)        .append_pair("client_id", client_id).append_pair("code_verifier", verifier);    if !client_secret.is_empty() {        form.append_pair("client_secret", client_secret);    }    Ok(json!({        "url": p.token,        "body": form.finish(),        "headers": { "content-type": "application/x-www-form-urlencoded", "accept": "application/json" },    }))}
OpenID Connect sign-in oidc.qwer · 1.1.0OpenID Connect sign-in: discovery, auth URLs with state, nonce and PKCE, the code exchanged, ID tokens checked against the provider's JWKS (RS256 and ES256, issuer, audience, expiry, nonce, at_hash, new keys fetched), userinfo, refresh and sign-out; and for a program calling an API as itself, the client credentials grant and access tokens checked (RFC 9068).sign-inoauthssojwt4 views · 8 downloadsJWKS jwks.qwer · 1.0.0Verify sign-in tokens from Auth0, Okta, Entra ID, Cognito or Keycloak against the provider's JWKS keys, picked by kid.authtokensssoapioidc8 views · 6 downloadsJWT jwt.qwer · 2.0.0JSON Web Tokens: sign and verify HS256 tokens for an API, check a token's audience and issuer, verify RS256, ES256 and EdDSA tokens against an identity provider's JWKS, peek at claims.authtokensapisso7 views · 9 downloadsTwo-factor codes totp.qwer · 1.0.0Two-factor sign-in with authenticator apps: secrets, the 6-digit codes, checking them, and the otpauth:// link to scan.auth2fatotpsign-in6 views · 9 downloadsArgon2 passwords argon2.qwer · 1.0.0Argon2id password hashes with the memory and passes you choose, any Argon2 hash verified, its parameters read, old ones marked for a rehash, and the cost timed on this server.passwordsauthsecuritymigration7 views · 7 downloadsBcrypt bcrypt.qwer · 1.0.0Sign in users whose bcrypt hashes came from PHP, Rails, Django or Laravel, then move them to Quillo's Argon2.authpasswordsmigration8 views · 10 downloads
Thread
66Useful? Like it, or add to its thread below.

Thread 0 posts

On OAuth PKCE: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.