Skip to main content

GitHub OAuth

WIP

GitHub Sign-in with PKCE-S256 and signed state. Default scopes read:user + user:email.

Setup

  1. Go to Settings -> Developer settings -> OAuth Apps in your GitHub account or organization.
  2. Click New OAuth App.
  3. Set Authorization callback URL to https://app.example.com/auth/github/callback.
  4. Copy the client ID and client secret.
import { authGithub } from '@gentleduck/auth/providers/oauth/github'

export const auth = new AuthEngine({
  // ...
  providers: [
    authGithub({
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
      redirectUri: 'https://app.example.com/auth/github/callback',
      stateSigningSecret: process.env.OAUTH_STATE_SECRET!,
      scopes: ['read:user', 'user:email'],  // default
      onSignIn: async ({ profile }) => {
        const primaryEmail = profile.emails.find((e) => e.primary)?.email

        let identity = await auth.identities.findByProviderSub({
          providerId: 'github',
          sub: String(profile.id),
        })
        if (!identity && primaryEmail) {
          identity = await auth.identities.findByEmail(primaryEmail)
        }
        if (!identity) {
          identity = await auth.identities.create({
            email: primaryEmail ?? `${profile.login}@users.noreply.github.com`,
            emailVerified: !!primaryEmail,
            profile: {
              displayName: profile.name ?? profile.login,
              avatarUrl: profile.avatar_url,
              githubLogin: profile.login,
            },
          })
        }
        await auth.identities.linkProvider(identity.id, {
          providerId: 'github',
          sub: String(profile.id),
          email: primaryEmail,
        })
        return { identityId: identity.id }
      },
    }),
  ],
})

Callback route

Same shape as every other OAuth provider:

// Express
app.get('/auth/github/callback', authMountSignIn(auth))

// Next.js - app/auth/github/callback/route.ts
export const GET = authNextSignIn(auth)

What the profile contains

GitHub's /user endpoint returns:

{
  id: 12345,                            // numeric, stable
  login: 'ada',                         // username
  name: 'Ada Lovelace',
  email: 'ada@example.com',            // may be null if user hides it
  avatar_url: 'https://avatars.githubusercontent.com/u/12345?v=4',
  bio: '...',
  company: '...',
  location: '...',
  // ... and many more
}

The provider also pulls the user's emails (with user:email scope) from /user/emails so you get the primary verified email even if they've hidden it from the public profile.

Restricting by org

For internal apps where only members of your GitHub org should sign in:

onSignIn: async ({ profile }) => {
  const orgs = await fetch('https://api.github.com/user/orgs', {
    headers: {
      Authorization: `Bearer ${profile._accessToken}`,
      'User-Agent': 'example-app',
    },
  }).then((r) => r.json())

  if (!orgs.some((o) => o.login === 'example-org')) {
    throw new AuthError('AUTH/PROVIDER_FAILED', {
      reason: 'not-in-org',
    })
  }

  // Add read:org to scopes so /user/orgs returns full list, not just public.
  // ...
}

If you need this often, scope the provider with read:org and call the API once per sign-in.

GitHub App vs OAuth App

duck-auth uses the OAuth App flow. If you have a GitHub App (which supports finer-grained permissions), you can still use the standard OAuth provider - the OAuth handshake is the same. You just lose the App-specific webhook integration.

Common errors

Error from GitHubduck-auth codeCause
redirect_uri_mismatchAUTH/PROVIDER_FAILEDCallback URL doesn't match.
bad_verification_codeAUTH/PROVIDER_FAILEDCode expired (10 minutes) or already used.
access_deniedAUTH/PROVIDER_FAILEDUser clicked Cancel on the consent screen.