Login or Register via OAuth
One-step login OR auto-register via Google, Apple, or X (Twitter). No captcha (provider handles bot protection). Server-side token verification.
POST /api/v1/auth/oauth/login — 🌐 Public · Rate limit: 10 req / hour · Captcha: NOT required (provider handles)
Single endpoint for OAuth flows: if the provider's user is unknown, a fresh BIO.RE account is auto-created from the verified profile. If known, the existing account is logged in. Returns accessToken + sets httpOnly refresh cookie.
Server-side verification is mandatory. The frontend obtains an idToken (Google/Apple) or code (any provider) from the SDK, sends it here; the backend re-verifies against the provider's public keys via OAuthVerifierService — never trusts the frontend's claim blindly.
Three login providers: google, apple, x. Despite 15 OAuth platforms in admin (OAuthProvider table), only these three are accepted for login. The other 12 (discord, github, instagram, linkedin, pinterest, reddit, spotify, threads, tiktok, twitch, youtube, facebook) are verify-only — used by creator/social/connect to attach social accounts, not to log in.
Request
Body — OAuthLoginDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
provider | 'google' | 'apple' | 'x' | ✓ | IsIn(OAUTH_PROVIDERS) | Active login provider |
idToken | string | conditional | max 5000 chars | Required for Google + Apple flows (alternative to code) |
code | string | conditional | max 2000 chars | Authorization code from any provider (alternative to idToken) |
codeVerifier | string | required for X | max 256 chars | PKCE code verifier — mandatory for X flow |
referralCode | string | — | — | Attribution if a brand-new account is auto-created |
At least one of idToken or code MUST be present. PKCE (codeVerifier) is X-specific.
Response
200 OK — ApiResponseOf<OAuthLoginResponseDto>
{
"success": true,
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": 900,
"isNewUser": false
}
}| Field | Type | Notes |
|---|---|---|
accessToken | string | Short-lived JWT (~15 min) |
expiresIn | number | Seconds to access token expiry |
isNewUser | boolean | true if this call auto-created a fresh BIO.RE account; client may show onboarding |
Refresh token is set as httpOnly cookie scoped to .bio.re.
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
400 | auth.oauth.provider_disabled | provider not in active login set OR admin disabled the platform |
400 | (DTO validation) | Missing both idToken and code; missing codeVerifier for X |
401 | auth.oauth.token_invalid | Provider rejected the token (signature, audience, expiry) |
409 | auth.oauth.email_exists | Account already exists under that email; response includes hasPassword + hasOAuth flags so client can prompt linking via POST /auth/oauth/link |
429 | (throttle) | Rate limit exceeded (10 req/hour) |
Side effects
- OAuthVerifierService.verify(provider, idToken, code, codeVerifier) — exchanges code for tokens (when
codeflow) and verifies idToken against provider's JWK set. - Lookup existing
SocialAccountby(provider, providerUserId). If found → loadUser, log in. - If not found, lookup
Userby verifiedemailfrom provider profile. If found → conflict (email_exists) — client must call/auth/oauth/link. - If neither match → auto-register: insert
User(verified email, no password),SocialAccount, optionalreferredByresolution,ConsentRecord(terms + privacy implicitly accepted via OAuth flow per platform policy). - Issue access + refresh JWT pair, set httpOnly cookie.
- Audit log:
auth.oauth.login.successor.register.success.
Code samples
# Google flow (idToken from Google Identity Services)
curl -X POST https://api.bio.re/api/v1/auth/oauth/login \
-H 'Content-Type: application/json' \
-c cookies.txt \
-d '{
"provider": "google",
"idToken": "<google_id_token>"
}'
# X flow (code + PKCE verifier)
curl -X POST https://api.bio.re/api/v1/auth/oauth/login \
-H 'Content-Type: application/json' \
-c cookies.txt \
-d '{
"provider": "x",
"code": "<authorization_code>",
"codeVerifier": "<pkce_code_verifier>"
}'type OAuthLoginInput = {
provider: 'google' | 'apple' | 'x';
idToken?: string;
code?: string;
codeVerifier?: string;
referralCode?: string;
};
type OAuthLoginResponse = {
accessToken: string;
expiresIn: number;
isNewUser: boolean;
};
async function oauthLogin(input: OAuthLoginInput): Promise<OAuthLoginResponse> {
const res = await fetch('https://api.bio.re/api/v1/auth/oauth/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'OAuth failed'), {
code: json?.error?.code,
});
}
return json.data;
}import { useMutation } from '@tanstack/react-query';
export function useOAuthLogin() {
return useMutation({
mutationFn: async (input: OAuthLoginInput) => {
const res = await fetch('/api/v1/auth/oauth/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'OAuth failed'), {
code: json?.error?.code,
i18nKey: json?.error?.i18nKey,
});
}
return json.data as OAuthLoginResponse;
},
onSuccess: (data) => {
router.push(data.isNewUser ? '/onboarding' : '/dashboard');
},
});
}Try it
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
application/json
application/json
application/json
curl -X POST "https://loading/api/v1/auth/oauth/login" \ -H "Content-Type: application/json" \ -d '{ "provider": "google" }'{
"success": true,
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIs...",
"expiresIn": 900,
"isNewUser": false
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/auth/oauth.controller.ts | 30–63 (login) |
| DTO (request) | apps/api-core/src/modules/auth/dto/oauth.dto.ts | 6–33 (OAuthLoginDto) |
| DTO (response) | apps/api-core/src/modules/auth/dto/response.dto.ts | 36–45 (OAuthLoginResponseDto) |
| Service (verify) | apps/api-core/src/modules/auth/oauth-verifier.service.ts | verify() |
| Service (login/register) | apps/api-core/src/modules/auth/oauth.service.ts | loginOrRegister() |
| Prisma models | packages/prisma/prisma/schema.prisma | User, SocialAccount, Session, ConsentRecord |