OAuth2 Provider
Problem
You want users to sign in with GitHub. Two flavors come up in real apps:
- Server-side redirect flow - your app hosts a "Sign in with GitHub" button. User is redirected to GitHub, GitHub redirects back to your callback, you exchange the code for an access token, look up (or create) a local user, and issue your own JWT.
- External access-token verification - a native mobile or SPA client runs the OAuth flow itself and sends GitHub's access token to your backend in the
Authorizationheader. Your backend verifies it against GitHub's API on every request.
Both are useful. The first issues a long-lived session via your own JWT (cheap to verify); the second never stores GitHub tokens but pings GitHub on each call. This recipe shows both, structured so the same provider class powers both flows.
Solution
This is the AuthProvider flavor - verifies an Authorization: token <github-access-token> header against the GitHub API and upserts the local user.
// src/auth/providers/github-auth.provider.ts
import { Injectable, inject, UnauthorizedException, type RequestContext } from '@miiajs/core'
import { fromHeader, type AuthProvider } from '@miiajs/auth'
import { UsersService } from '../../users/users.service.js'
interface GitHubProfile {
id: number
login: string
name: string | null
email: string | null
avatar_url: string
}
@Injectable()
export class GitHubAuth implements AuthProvider {
private usersService = inject(UsersService)
private extract = fromHeader('authorization', 'token')
async authenticate(ctx: RequestContext) {
const accessToken = this.extract(ctx)
if (!accessToken) throw new UnauthorizedException('Missing GitHub token')
const profile = await this.fetchProfile(accessToken)
return this.usersService.upsertFromOAuth({
provider: 'github',
externalId: String(profile.id),
email: profile.email,
name: profile.name ?? profile.login,
avatar: profile.avatar_url,
})
}
private async fetchProfile(token: string): Promise<GitHubProfile> {
const res = await fetch('https://api.github.com/user', {
headers: {
authorization: `token ${token}`,
'user-agent': 'miia-app',
accept: 'application/vnd.github+json',
},
})
if (!res.ok) throw new UnauthorizedException('Invalid GitHub token')
return res.json() as Promise<GitHubProfile>
}
}
Use it on any route that accepts a GitHub token directly:
@Controller('/api')
@UseGuard(AuthGuard(GitHubAuth))
class ApiController {
@Get('/profile')
profile(ctx: RequestContext) {
return ctx.user
}
}
Or stack it with a JWT provider so a route accepts either flavor:
@UseGuard(AuthGuard(JwtAuth, GitHubAuth))
The JWT provider runs first (cheap local verification); the GitHub provider runs only as fallback (one network round-trip).
Server-side redirect flow
For the "Sign in with GitHub" button on your own site, you want a redirect endpoint and a callback endpoint. The callback then issues your own JWT so the rest of your app never has to touch GitHub.
// src/auth/oauth2/github-oauth.controller.ts
import { Controller, Get, inject, BadRequestException, type RequestContext } from '@miiajs/core'
import { ConfigService } from '@miiajs/config'
import { JwtService } from '@miiajs/jwt'
import { GitHubAuth } from '../providers/github-auth.provider.js'
@Controller('/auth/github')
export class GitHubOAuthController {
private configService = inject(ConfigService)
private jwtService = inject(JwtService)
private gitHubAuth = inject(GitHubAuth)
@Get('/')
redirect(ctx: RequestContext) {
const url = new URL('https://github.com/login/oauth/authorize')
url.searchParams.set('client_id', this.configService.getOrThrow('GITHUB_CLIENT_ID'))
url.searchParams.set('redirect_uri', this.configService.getOrThrow('GITHUB_REDIRECT_URI'))
url.searchParams.set('scope', 'read:user user:email')
url.searchParams.set('state', this.issueState(ctx))
return ctx.res.redirect(url.toString())
}
@Get('/callback')
async callback(ctx: RequestContext) {
const code = ctx.query.code
const state = ctx.query.state
if (!code) throw new BadRequestException('Missing code')
this.verifyState(ctx, state)
const accessToken = await this.exchangeCode(code)
// Reuse the provider - synthesize a context with the GitHub token in the header.
const fakeCtx = {
req: new Request('http://internal/', {
headers: { authorization: `token ${accessToken}` },
}),
} as RequestContext
const user = await this.gitHubAuth.authenticate(fakeCtx)
// Issue OUR jwt so subsequent requests don't hit GitHub.
const token = await this.jwtService.sign({ sub: (user as { id: number }).id })
return ctx.res.redirect(`/?token=${encodeURIComponent(token)}`)
}
private async exchangeCode(code: string): Promise<string> {
const res = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify({
client_id: this.configService.getOrThrow('GITHUB_CLIENT_ID'),
client_secret: this.configService.getOrThrow('GITHUB_CLIENT_SECRET'),
code,
}),
})
if (!res.ok) throw new BadRequestException('GitHub code exchange failed')
const json = (await res.json()) as { access_token?: string; error?: string }
if (!json.access_token) throw new BadRequestException(json.error ?? 'No access token')
return json.access_token
}
/** Issue a signed CSRF state, valid for 10 minutes. */
private issueState(_ctx: RequestContext): string {
// Use JwtService for a short-lived signed nonce, or store in a cache keyed to session.
return crypto.randomUUID()
}
/** Verify the state matches what we issued. */
private verifyState(_ctx: RequestContext, _state: string | undefined) {
// Compare against the stored value (cookie, session, redis). Throw on mismatch.
}
}
Two GitHub-related decisions worth calling out. First, after the callback we issue our own JWT - we do not store or pass around GitHub's access token for normal API requests. That avoids hitting GitHub on every call and keeps token revocation in our hands. Second, the callback reuses
GitHubAuth.authenticate()by synthesizing a minimalRequestContext- the upsert logic lives in one place, not two.
Module wiring
// src/auth/auth.module.ts
import { Module } from '@miiajs/core'
import { AuthController } from './auth.controller.js'
import { GitHubOAuthController } from './oauth2/github-oauth.controller.js'
import { GitHubAuth } from './providers/github-auth.provider.js'
@Module({
controllers: [AuthController, GitHubOAuthController],
providers: [GitHubAuth],
})
export class AuthModule {}
UsersService.upsertFromOAuth
async upsertFromOAuth(input: {
provider: 'github' | 'google' | 'apple'
externalId: string
email: string | null
name: string
avatar: string | null
}) {
const [existing] = await this.db
.select()
.from(users)
.where(and(eq(users.provider, input.provider), eq(users.externalId, input.externalId)))
if (existing) return existing
const [created] = await this.db
.insert(users)
.values({
provider: input.provider,
externalId: input.externalId,
email: input.email,
name: input.name,
avatar: input.avatar,
})
.returning()
return created
}
For OAuth users you don't store a passwordHash - make the column nullable, or use a discriminator column (auth_type: 'local' | 'oauth').
Security notes
CSRF via state parameter
The OAuth state parameter is your CSRF protection for the callback. The simplest approach: generate a UUID, set it in an httpOnly cookie, and compare on callback. For stateless apps, sign a short-lived JWT containing a nonce and store the nonce in a cache.
Don't skip this. Without state, a malicious site can trick a logged-in user's browser into completing an OAuth flow against an attacker-controlled GitHub account.
See also
@miiajs/auth-AuthProviderinterface@miiajs/jwt- issuing your own session JWT after callback- JWT Provider - verify the JWT issued by the callback
- Local Provider - username/password login alongside OAuth