# A complete guide to creating and installing an external GitHub OAuth plugin for Suprnova, configuring a GitHub OAuth App, registering the provider, and handling the callback safely.
--

### Build GitHub sign-in into a Suprnova application with a real external OAuth provider, verified primary email handling, PKCE, safe account linking, and application-grant revocation. 

GitHub sign-in looks like a small OAuth integration until you reach the email address. The basic profile endpoint returns an email only when the user made it public, and public does not mean verified. If an authentication plugin quietly treats that field as proof of account ownership, a convenient login button becomes an account-linking vulnerability.

The external [`suprnova-oauth-github`](https://github.com/eas4ai/suprnova-oauth-github) plugin handles that boundary explicitly. It uses GitHub's stable numeric user ID as the external identity, fetches the authenticated user's email list, and accepts an address only when GitHub marks it both primary and verified.

It is also a useful example of how to build against Suprnova's public OAuth SDK. The plugin lives in its own repository, depends on the published Suprnova `v1.3.2` tag, and imports everything through `suprnova::`. There is no path dependency, workspace membership, direct Magnetar dependency, or private framework hook.

This guide installs the plugin, creates the GitHub OAuth App, registers the provider, and adds the two application routes needed to sign in.

## What the plugin does

The plugin supplies two public pieces:

- `GitHubOAuthProvider` implements Suprnova's public `OAuthProvider` contract. It declares GitHub's endpoints and request shapes, maps a GitHub profile into a verified provider identity, and renders grant-revocation requests.
- `GitHubOAuthTransport` wraps Suprnova's public `OAuthHttpTransport`. It performs the two GitHub REST requests needed for identity resolution and combines their responses before the provider parses them.

Suprnova still owns the security-sensitive parts of the OAuth ceremony:

- It generates and validates state.
- It generates the PKCE verifier and sends the `S256` challenge.
- It binds the ceremony to the initiating framework session.
- It adds the bearer `Authorization` header to user-information requests.
- It applies the configured account-linking and factor-gate policies.
- It creates the application session after the provider identity has been verified.

The provider never performs network I/O inside `resolve_identity`. The host transport performs the network calls, which keeps the plugin on the same public boundary used by every other external Suprnova provider.

You can learn how to build a plugin here: 
[https://github.com/eas4ai/suprnova-oauth-github/blob/main/BUILDING.md](https://github.com/eas4ai/suprnova-oauth-github/blob/main/BUILDING.md)

## Why GitHub email needs two requests

GitHub's authenticated-user endpoint is:

```text
GET https://api.github.com/user
```

That response includes a stable numeric `id`, the mutable `login`, and profile fields such as `name`. Its `email` field is populated only when the user made an address public. It does not carry a separate verification flag.

The plugin also requests:

```text
GET https://api.github.com/user/emails?per_page=100
```

That endpoint requires the `user:email` scope and returns `primary` and `verified` for each address. The plugin accepts exactly one address matching both conditions:

```json
{
  "email": "developer@example.com",
  "primary": true,
  "verified": true,
  "visibility": "private"
}
```

A public but unverified address is ignored. A verified secondary address is also ignored. If GitHub does not return one verified primary address, the provider returns no email and Suprnova fails closed instead of guessing.

## Create a GitHub OAuth App

Create one OAuth App for each environment whose callback URL differs.

1. Open [GitHub Developer settings](https://github.com/settings/developers).
2. Select **OAuth Apps**.
3. Select **New OAuth App**.
4. Enter your application's public URL as the **Homepage URL**.
5. Enter the callback route as the **Authorization callback URL**. For example:

   ```text
   https://app.example.com/auth/github/callback
   ```

6. Create the app.
7. Generate a client secret.
8. Store the client ID and secret in your deployment's secret manager.

The callback URL in your application configuration must match the URL registered with GitHub.

## Add the dependencies

Add the released plugin and the Suprnova SDK tag it targets:

```toml
[dependencies]
suprnova = { git = "https://github.com/eas4ai/suprnova.git", tag = "v1.3.2" }
suprnova-oauth-github = { git = "https://github.com/eas4ai/suprnova-oauth-github.git", tag = "v0.1.0" }
url = "2"
```

The `url` dependency decodes the callback query string in the controller below. The provider itself uses `suprnova::SecretString`, so the application does not need a direct Magnetar dependency.

## Configure the environment

Add these values to your local environment and production secret configuration:

```dotenv
GITHUB_OAUTH_CLIENT_ID=your_github_oauth_client_id
GITHUB_OAUTH_CLIENT_SECRET=your_github_oauth_client_secret
GITHUB_OAUTH_REDIRECT_URI=https://app.example.com/auth/github/callback
GITHUB_OAUTH_USER_AGENT=example-app/1.0 (security@example.com)
```

GitHub requires a `User-Agent` on REST requests. Use a stable product identifier and a monitored contact address. Do not put credentials in the value.

## Register GitHub during bootstrap

Register GitHub on the same `MagnetarConfig` that initializes the rest of your authentication stack. Suprnova builds the OAuth service first, then publishes password, passkey, and OAuth engines atomically. If any part of initialization fails, none of the engines becomes visible.

```rust
use std::env;
use std::sync::Arc;

use suprnova::{
    App, AutoLinkPolicy, DB, FrameworkAbuseLimiter, FrameworkError,
    MagnetarConfig, MagnetarOAuthHostConfig, MagnetarOAuthProviderConfig,
    OAuthAuthorizationConfig, OAuthHttpTransport, RateLimiterDriver,
    ReqwestOAuthTransport, SecretString, init_magnetar,
};
use suprnova_oauth_github::{
    GitHubEndpoints, GitHubOAuthProvider, GitHubOAuthTransport,
    GitHubProviderConfig,
};

pub async fn register_github_oauth() -> Result<(), FrameworkError> {
    let client_id = required_env("GITHUB_OAUTH_CLIENT_ID")?;
    let client_secret = required_env("GITHUB_OAUTH_CLIENT_SECRET")?;
    let redirect_uri = required_env("GITHUB_OAUTH_REDIRECT_URI")?;
    let user_agent = required_env("GITHUB_OAUTH_USER_AGENT")?;
    let endpoints = GitHubEndpoints::default();

    let base: Arc<dyn OAuthHttpTransport> =
        Arc::new(ReqwestOAuthTransport::try_default()?);
    let transport = Arc::new(
        GitHubOAuthTransport::try_new(base, endpoints.clone())
            .map_err(|error| FrameworkError::internal(error.to_string()))?,
    );
    let provider = Arc::new(
        GitHubOAuthProvider::try_new(
            GitHubProviderConfig {
                client_id,
                client_secret: SecretString::from(client_secret),
                user_agent,
                endpoints,
            },
            transport.clone(),
        )
        .map_err(|error| FrameworkError::internal(error.to_string()))?,
    );
    let limiter = Arc::new(FrameworkAbuseLimiter::new(
        App::resolve_make::<dyn RateLimiterDriver>()?,
    ));
    let oauth = MagnetarOAuthHostConfig::new(
        vec![MagnetarOAuthProviderConfig {
            provider,
            redirect_uri,
            scopes: vec!["user:email".to_owned()],
        }],
        transport,
        limiter,
        OAuthAuthorizationConfig::default(),
        AutoLinkPolicy::default(),
    )
    .map_err(|_| {
        FrameworkError::internal("invalid GitHub OAuth host configuration")
    })?;

    let database = DB::connection()?;
    init_magnetar(
        MagnetarConfig::from_sea_orm(database.inner().clone()).oauth(oauth),
    )
    .await
}

fn required_env(name: &'static str) -> Result<String, FrameworkError> {
    env::var(name)
        .map_err(|_| FrameworkError::internal(format!("{name} is not set")))
}
```

Call `register_github_oauth().await` during application bootstrap after the database, encryption key, session store, and rate-limiter driver have been registered. Call `init_magnetar` only once.

`ReqwestOAuthTransport::try_default()` gives the plugin the framework's production HTTP posture: redirects are disabled, requests time out after 30 seconds, responses are limited to 1 MiB, and a default Suprnova `User-Agent` is available. The provider-specific value from `GITHUB_OAUTH_USER_AGENT` is sent on GitHub REST requests.

`FrameworkAbuseLimiter` uses the application's configured `RateLimiterDriver`. Production deployments normally use the shared Redis driver, so OAuth start attempts are limited consistently across application processes.

## Add the routes

The application owns the HTTP routes. Add one route to start the ceremony and one for GitHub's callback:

```rust
get!("/auth/github", controllers::github_oauth::start),
get!(
    "/auth/github/callback",
    controllers::github_oauth::callback
),
```

Apply `SessionMiddleware` to both routes. Suprnova binds the OAuth ceremony to a digest of the initiating session. Moving the callback to another browser session causes validation to fail.

## Add the controller

Create `src/controllers/github_oauth.rs`:

```rust
use std::collections::HashMap;

use suprnova::{
    Auth, FrameworkError, HttpResponse, Request, Response,
};

pub async fn start(_request: Request) -> Response {
    start_inner().await.map_err(HttpResponse::from)
}

async fn start_inner() -> Result<HttpResponse, FrameworkError> {
    let kickoff = Auth::oauth("github").begin().await?;

    Ok(HttpResponse::new()
        .status(302)
        .header("Location", kickoff.authorization_url))
}

pub async fn callback(request: Request) -> Response {
    callback_inner(request).await.map_err(HttpResponse::from)
}

async fn callback_inner(
    request: Request,
) -> Result<HttpResponse, FrameworkError> {
    let params = query_parameters(&request);
    if params.contains_key("error") {
        return Err(FrameworkError::bad_request(
            "GitHub authorization was denied",
        ));
    }

    let code = params
        .get("code")
        .ok_or_else(|| {
            FrameworkError::bad_request("missing GitHub OAuth code")
        })?;
    let state = params
        .get("state")
        .ok_or_else(|| {
            FrameworkError::bad_request("missing GitHub OAuth state")
        })?;

    let (_user, _session) = Auth::oauth("github")
        .complete(code, state)
        .await?;

    Ok(HttpResponse::new().status(302).header("Location", "/"))
}

fn query_parameters(request: &Request) -> HashMap<String, String> {
    url::form_urlencoded::parse(
        request.query().unwrap_or("").as_bytes(),
    )
    .into_owned()
    .collect()
}
```

`begin()` persists a single-use ceremony, generates state and PKCE values, and returns GitHub's authorization URL. The controller only has to redirect the browser.

`complete()` exchanges the callback code, validates the session-bound state, fetches the GitHub profile and email list, resolves the linked identity, applies account-link and factor policy, rotates the framework session, and returns the application user and Magnetar session values.

## What GitHub receives

The authorization redirect includes:

- `client_id`
- `redirect_uri`
- `scope=user:email`
- An unguessable `state`
- A PKCE `code_challenge`
- `code_challenge_method=S256`

The token exchange sends the single-use code, the original redirect URI, the PKCE verifier, the client ID, and the client secret as a form-encoded request. It asks GitHub for a JSON response with `Accept: application/json`.

The profile and email requests include:

```text
Authorization: Bearer <access-token>
User-Agent: example-app/1.0 (security@example.com)
Accept: application/vnd.github+json
X-GitHub-Api-Version: 2026-03-10
```

Suprnova, not the provider, adds the bearer header. The SDK rejects any provider that tries to replace it.

## Handle a missing verified primary email

Most GitHub accounts have a verified primary address, including accounts that keep it private. The `user:email` scope lets the plugin verify that private address without making it public.

If GitHub does not return exactly one verified primary address, the plugin returns `email: None`. `Auth::oauth("github").complete(...)` then returns an HTTP `409 Conflict` response with this message:

```text
OAuth identity requires verified email completion
```

A basic application can ask the user to verify a primary address in GitHub and restart sign-in. If your application already has a separate verified-email completion or explicit account-linking flow, route the conflict into that flow. Do not weaken the provider by trusting `GET /user`'s public email field or a secondary address.

## Account linking stays explicit

The default `AutoLinkPolicy` does not silently attach a GitHub identity to an
existing account merely because the email strings match. This applies whether
the existing account's email is verified or still unverified. The authenticated
owner must explicitly authorize the link.

The verified primary email still matters for safe new-account creation. When
no account owns that normalized address, Magnetar can create the application
user and linked GitHub identity through its verified-provider transaction. A
matching existing account instead produces the explicit-link outcome rather
than transferring ownership.

GitHub's numeric `id` remains the provider subject throughout this process. A user can rename their GitHub login without creating a second application identity.

## Revoke the GitHub grant

GitHub revocation is not the generic RFC 7009 form. The plugin sends the application-grant request GitHub documents:

```text
DELETE /applications/{client_id}/grant
Authorization: Basic base64(client_id:client_secret)
Accept: application/vnd.github+json
X-GitHub-Api-Version: 2026-03-10
Content-Type: application/json

{"access_token":"..."}
```

GitHub removes the application's grant and all OAuth tokens associated with that user. Version `0.1.0` does not enable GitHub's optional expiring-token and refresh-token mode.

## Test the complete flow

1. Start the application with the four `GITHUB_OAUTH_*` values set.
2. Open `/auth/github` in a browser.
3. Approve the `user:email` scope on GitHub.
4. Confirm that GitHub returns to the exact callback URL registered for the OAuth App.
5. Confirm that the callback signs the user in and redirects to `/`.
6. Sign out and repeat the flow.
7. Confirm that the second sign-in resolves the same account, even if the GitHub login has changed.

Do not test by inventing a callback `code` and sending it directly. Start the flow through the same browser session so state and PKCE validation exercise the real boundary.

The plugin repository also carries an offline integration suite. It drives `Auth::oauth("github").begin()` and `verify_oauth_identity(...)` through the public Suprnova engine against an in-process mock GitHub server. The test verifies the token exchange, PKCE, required headers, `/user`, `/user/emails`, and identity mapping without contacting GitHub.com.

Run the project checks with:

```bash
cargo fmt --all --check
cargo clippy --all-targets
cargo test --all-targets
cargo check --example suprnova_app
```

## Use GitHub Enterprise Server

Compatible GitHub Enterprise Server installations can use explicit endpoints:

```rust
let endpoints = GitHubEndpoints {
    authorization: "https://github.example.com/login/oauth/authorize".into(),
    token: "https://github.example.com/login/oauth/access_token".into(),
    user: "https://github.example.com/api/v3/user".into(),
    emails: "https://github.example.com/api/v3/user/emails?per_page=100".into(),
    revocation: concat!(
        "https://github.example.com/api/v3/applications/",
        "{client_id}/grant",
    )
    .into(),
};
```

The plugin requires HTTPS outside loopback tests. The user and email endpoints must share the same origin because the transport forwards the same bearer token to both.

Confirm that your GitHub Enterprise Server version supports PKCE and the configured REST API version before deployment. The plugin sends `X-GitHub-Api-Version: 2026-03-10` in version `0.1.0`.

## The external SDK boundary is the point

The implementation is intentionally ordinary. A developer can reproduce its dependency graph from the public tags:

```text
suprnova v1.3.2             10d94d2d
suprnova-oauth-github v0.1.0 96ad9926
```

A clean consumer project using those two Git tags compiles the provider, transport, limiter, and `MagnetarOAuthHostConfig` without access to either repository's workspace. The plugin's source firewall rejects a direct Magnetar dependency, a path dependency, or a Magnetar import.

That gives Suprnova applications a real GitHub sign-in option today, and it demonstrates the contract every external OAuth provider can use: provider-specific proof at the edge, host-owned transport and session authority, and no private shortcut between them.

Install the plugin from [GitHub](https://github.com/eas4ai/suprnova-oauth-github) or start with its [complete README tutorial](https://github.com/eas4ai/suprnova-oauth-github#readme).