Link OAuth Provider
Attach an OAuth provider (Google / Apple / X) to the currently authenticated account. Lets users add Sign-in-with after registering with email.
POST /api/v1/auth/oauth/link — 🔑 User-auth (Bearer JWT) · Rate limit: 20 req / hour
Links an OAuth identity to the current user. Useful when a user registered with email + password and later wants to add Google/Apple/X login as an alternative sign-in.
The provider account is unique per platform: an OAuth identity that's already linked to another user returns 409 conflict. Server-side verification of the token (same OAuthVerifierService as /auth/oauth/login) prevents claim forgery.
Request
Headers
| Header | Value | Notes |
|---|---|---|
Authorization | Bearer <accessToken> | Required |
Content-Type | application/json | Required |
Body — OAuthLinkDto
Same shape as OAuthLoginDto minus referralCode:
| Field | Type | Required | Notes |
|---|---|---|---|
provider | 'google' | 'apple' | 'x' | ✓ | IsIn(OAUTH_PROVIDERS) |
idToken | string | conditional | Google / Apple flow |
code | string | conditional | Code flow |
codeVerifier | string | required for X | PKCE |
Response
200 OK — ApiResponseOf<MessageResponseDto>
{ "success": true, "data": { "message": "Provider linked successfully" } }Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
400 | auth.oauth.already_linked | This user already has the provider linked |
400 | auth.oauth.provider_disabled | Admin disabled this platform |
400 | (DTO validation) | Missing both idToken and code; missing codeVerifier for X |
401 | (no JWT or invalid) | Not authenticated OR token verification failed |
409 | auth.oauth.linked_to_other_user | This OAuth identity is already linked to a different user |
429 | (throttle) | Rate limit exceeded (20 req/hour) |
Side effects
- Verify token via
OAuthVerifierService.verify(). - Check existing
SocialAccountfor(provider, providerUserId):- If linked to current user → return
400 already_linked. - If linked to another user → return
409 linked_to_other_user.
- If linked to current user → return
- Insert
SocialAccountrow tying current user to provider profile. - Audit log:
auth.oauth.link.success. - Send notification email: "
{Provider}account linked to your BIO.RE login on{date}".
Code samples
curl -X POST https://api.bio.re/api/v1/auth/oauth/link \
-H 'Authorization: Bearer <accessToken>' \
-H 'Content-Type: application/json' \
-d '{
"provider": "google",
"idToken": "<google_id_token>"
}'type OAuthLinkInput = {
provider: 'google' | 'apple' | 'x';
idToken?: string;
code?: string;
codeVerifier?: string;
};
async function linkOAuth(input: OAuthLinkInput, accessToken: string): Promise<{ message: string }> {
const res = await fetch('https://api.bio.re/api/v1/auth/oauth/link', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify(input),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Link failed'), {
code: json?.error?.code,
});
}
return json.data;
}import { useMutation, useQueryClient } from '@tanstack/react-query';
import { meKeys } from './use-me';
export function useOAuthLink() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (input: OAuthLinkInput) => {
const res = await fetch('/api/v1/auth/oauth/link', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getAccessToken()}`,
},
body: JSON.stringify(input),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Link failed'), {
code: json?.error?.code,
i18nKey: json?.error?.i18nKey,
});
}
return json.data;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: meKeys.identity });
toast.success(t('auth.oauth.linked'));
},
});
}Try it
Authorization
bearer In: header
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/link" \ -H "Content-Type: application/json" \ -d '{ "provider": "google" }'{
"success": true,
"data": {
"message": "Operation completed successfully"
}
}{
"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 | 67–88 (link) |
| DTO (request) | apps/api-core/src/modules/auth/dto/oauth.dto.ts | 35–57 (OAuthLinkDto) |
| Service (verify) | apps/api-core/src/modules/auth/oauth-verifier.service.ts | verify() |
| Service (link) | apps/api-core/src/modules/auth/oauth.service.ts | linkProvider() |
| Prisma model | packages/prisma/prisma/schema.prisma | SocialAccount, User |