Manual contentsSecurityBrowse 103 chapters
Manual 8 min read

OAuth, Apple & Magic-Link Login

Suprnova ships three torii-backed login methods behind the Auth facade: generic OAuth (GitHub, Google, or any OIDC/OAuth2 provider), Sign in with Apple, and passwordless magic links. They share one prerequisite (init_torii plus the ceremony migration) and the same facade shape - Auth::oauth(provider) / Auth::magic_link() - and none of them ship routes: you add a thin controller (start + callback) and the framework does the CSRF state, PKCE, token exchange, identity verification, user upsert, and session minting.

The whole surface lives in framework/src/torii_integration/. There is no framework env-var contract for any of it - every credential is passed programmatically (pull your own from the environment); this chapter's examples use std::env::var(...) purely to show where your secrets go.

Prerequisites

  1. Initialise torii once at boot - this backs the user upsert and session creation:

    use suprnova::{init_torii, ToriiConfig};
    
    // in bootstrap::register(), after DB::init()
    init_torii(ToriiConfig::from_sea_orm(db_conn)).await?;
    
  2. Run the ceremony migration. OAuth and Apple stash a short-lived (10-minute) CSRF-state + PKCE ceremony in the auth_ceremony_tokens table. Register the migration m20251209_000000_create_auth_ceremony_tokens_table in your Migrator (the starter kits already include it). Optionally schedule suprnova::torii_integration::ceremony::prune_expired() to GC stale rows.

  3. SessionMiddleware on the OAuth start route. begin() writes the state into the session; a sessionless call fails with a 500.

Magic links need only step 1.

Generic OAuth (GitHub, Google, custom)

Configure a provider

Register each provider once at startup. The registry is process-global and idempotent, so re-registering the same provider just replaces the config:

use suprnova::Auth;
use suprnova::torii_integration::oauth::OAuthProviderConfig;

Auth::oauth("github").configure(OAuthProviderConfig {
    client_id: std::env::var("GITHUB_CLIENT_ID")?,
    client_secret: std::env::var("GITHUB_CLIENT_SECRET")?,
    redirect_url: "https://app.example.com/auth/oauth/github/callback".into(),
    scopes: vec!["user:email".into()],
    endpoints_override: None,   // None → the built-in well-known table
    apple_key_pair: None,       // Apple-only; leave None for GitHub/Google
    apple_team_id: None,        // Apple-only
});

The well-known authorize/token/userinfo endpoints are built in for github, google, and apple. For any other provider - or a self-hosted / test server - supply them yourself:

use suprnova::torii_integration::oauth::EndpointOverrides;

Auth::oauth("gitlab").configure(OAuthProviderConfig {
    client_id: /**/,
    client_secret: /**/,
    redirect_url: /**/,
    scopes: vec!["read_user".into()],
    endpoints_override: Some(EndpointOverrides {
        authorize: "https://gitlab.com/oauth/authorize".into(),
        token: "https://gitlab.com/oauth/token".into(),
        userinfo: "https://gitlab.com/api/v4/user".into(),
        emails: None,   // GitHub-style /emails fallback for a private primary
    }),
    apple_key_pair: None,
    apple_team_id: None,
});

Start the flow (authorize URL)

// GET /auth/oauth/github/start  (route MUST carry SessionMiddleware)
let kickoff = Auth::oauth("github").begin().await?;
// kickoff.authorization_url - redirect the browser here
// kickoff.state - CSRF state, already stored in the session for you

begin() mints the CSRF state (UUID v4) and an RFC 7636 PKCE verifier/S256 challenge, records the ceremony (10-minute TTL), and returns the provider authorize URL. Redirect the user to authorization_url.

Complete the flow - verify vs complete

On the callback you have two entry points (split in 0.5.4). Pick by whether your users table is torii's schema:

Method Returns Side effects Use when
verify_oauth_identity(code, state) OAuthIdentity { provider, subject, email, name } None - verifies the ceremony, exchanges the code, fetches userinfo, extracts a verified email + stable subject. No user, no session. Your app owns its users table and you want to look up / create the user yourself.
complete(code, state) (User, Session) Upserts the user into torii (get_or_create_user) and mints a session. Your users table is torii's schema.
// Custom users table:
let id = Auth::oauth("github").verify_oauth_identity(&code, &state).await?;
// id.subject is the stable provider id; id.email is verified-or-None.
let user = my_users::upsert(id.provider, id.subject, id.email, id.name).await?;

// …or, torii-backed:
let (user, session) = Auth::oauth("github").complete(&code, &state).await?;

A verify-returned email is always a verified address (OIDC email_verified, GitHub treated as verified, or the /emails fallback); an unverified or absent email comes back as None and repeat logins resolve by subject.

Routes you add

The framework provides no OAuth routes - wire two thin handlers (mirror the shape of the existing auth_verify / auth_reset controllers in the starter kit):

// start - redirects to the provider
get!("/auth/oauth/{provider}/start", controllers::oauth::start),
// callback - GitHub/Google use GET ?code&state
get!("/auth/oauth/{provider}/callback", controllers::oauth::callback),

Put the /start route (at least) behind SessionMiddleware.

Sign in with Apple

Apple is the same facade - Auth::oauth("apple") - with a few Apple-specific rules baked in:

  • The callback is a POST. Apple uses response_mode=form_post, so the redirect delivers code + state in a form body, not query params. Register the Apple callback as a post! route and read the fields from the form.
  • No PKCE. Apple rejects code_challenge, so the authorize URL omits it (the client secret is a signed JWT instead).
  • client_secret is unused - leave it String::new(). Suprnova mints the short-lived JWT client secret from your .p8 key on each token exchange.
  • ID tokens are verified against Apple's JWKS (RS256) since 0.5.6, not trusted structurally.

Supply your Apple key - AppleKeyPair

AppleKeyPair is the one Apple type re-exported for apps (so you need no direct apple dependency). Build it from your .p8 signing key:

use suprnova::torii_integration::oauth::AppleKeyPair;

let key = AppleKeyPair::from_file(
    &std::env::var("APPLE_KEY_ID")?,   // Apple *Key ID* (not the Team ID)
    &std::env::var("APPLE_P8_PATH")?,  // path to AuthKey_XXXXXX.p8
)?;
// or: AppleKeyPair::from_base64(key_id, b64)  /  from_pem_bytes(key_id, bytes)

Configure Apple

use suprnova::torii_integration::oauth::OAuthProviderConfig;

Auth::oauth("apple").configure(OAuthProviderConfig {
    client_id: std::env::var("APPLE_CLIENT_ID")?,  // your Services ID
    client_secret: String::new(),                  // unused - minted from the key
    redirect_url: "https://app.example.com/auth/apple/callback".into(),
    scopes: vec!["email".into(), "name".into()],
    endpoints_override: None,
    apple_key_pair: Some(key),
    apple_team_id: Some(std::env::var("APPLE_TEAM_ID")?),  // 10-char Team ID
});

Complete the Apple flow

Same split as generic OAuth. complete upserts + sessions; the verify path returns an AppleIdentity for a custom users table:

// POST /auth/apple/callback - read code + state from the FORM body
let (user, session) = Auth::oauth("apple").complete(&code, &state).await?;

// …or custom users table:
let id = Auth::oauth("apple").verify_apple_identity(&code, &state).await?;
// id: AppleIdentity { provider, subject, email, email_verified, is_private_email }

AppleIdentity.email is Some(_) only when Apple asserts it verified; an unverified email is refused (401) before the identity is built. is_private_email is set when the user chose Apple's private-relay address - persist the subject as the stable key, since the relay address is the only email you'll get.

Passwordless email login, torii-backed, via Auth::magic_link(). The framework issues and verifies the token; you email the link (it never sends mail itself), which composes cleanly with the Mail chapter.

use suprnova::Auth;

// POST /auth/magic - request a link
let token = Auth::magic_link()
    .send("alice@example.com", "https://app.example.com/auth/magic")
    .await?;
// Build the link and email it yourself:
Mail::to("alice@example.com")
    .send(MagicLink { url: format!("https://app.example.com/auth/magic?token={token}") })
    .await?;

// GET /auth/magic?token=… - consume it (single-use; a second call fails)
let (user, session) = Auth::magic_link().consume(&token).await?;

The user is auto-created on first use. send returns the plaintext token so you control the URL shape and delivery.

Note - TokenPurpose::MagicLink. The auth_flows TokenPurpose enum has a MagicLink variant (added in 0.5.5), but it is a reserved discriminator for the generic TokenStore - no built-in flow consumes it. The working, supported magic-link path is Auth::magic_link() above. Only reach for TokenPurpose::MagicLink if you are hand-rolling your own flow on the auth_flow_tokens table.

A note on configuration

None of these methods read framework environment variables - provider IDs, secrets, redirect URLs, and Apple keys are all passed to configure(...) programmatically. Load them however you like (std::env::var, a typed config struct, a secret manager) and register providers once during bootstrap. This keeps multi-tenant / per-deploy provider setups first-class instead of forcing a fixed env-var naming scheme.

Reference

  • Facade entry points: Auth::oauth(provider), Auth::magic_link() (suprnova::Auth)
  • Config: suprnova::torii_integration::oauth::{OAuthProviderConfig, EndpointOverrides, AppleKeyPair}
  • OAuth results: OAuthKickoff { authorization_url, state }, OAuthIdentity { provider, subject, email, name }, AppleIdentity { provider, subject, email, email_verified, is_private_email }
  • Bootstrap: suprnova::{init_torii, ToriiConfig}
  • Ceremony store: auth_ceremony_tokens table + suprnova::torii_integration::ceremony::prune_expired()

Next

  • Authentication - guards, providers, and the Authenticatable user model these flows create sessions for
  • Auth Flows - email verification, password reset, and 2FA
  • Mail - sending the magic-link email (and the MAIL_FROM / MAIL_FROM_NAME sender config)
  • Sessions - what the returned Session is and how it's persisted