Unlink OAuth Provider
Remove an OAuth provider link from the current account. Refuses to unlink the only sign-in method (anti-lockout).
DELETE /api/v1/auth/oauth/unlink/{provider} — 🔑 User-auth (Bearer JWT) · Rate limit: 20 req / hour
Detaches an OAuth identity from the current user. Refuses to unlink if it's the user's only sign-in method (no password + no other OAuth) — protects against accidental lockout.
Anti-lockout guard: if the user has no User.passwordHash AND only this provider is linked, the request returns 400 only_auth_method. Set a password first via the password reset flow if you want to unlink the only OAuth login.
Request
Headers
| Header | Value | Notes |
|---|---|---|
Authorization | Bearer <accessToken> | Required |
Path parameters
| Param | Type | Validation | Notes |
|---|---|---|---|
provider | 'google' | 'apple' | 'x' | Allowlisted in controller (validProviders) | Hardcoded check — non-login providers (discord, github, etc.) rejected |
No body, no query.
Response
200 OK — ApiResponseOf<MessageResponseDto>
{ "success": true, "data": { "message": "Provider unlinked successfully" } }Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
400 | auth.oauth.provider_disabled | provider not in ['google', 'apple', 'x'] |
400 | auth.oauth.not_linked | Current user has no SocialAccount row for this provider |
400 | auth.oauth.only_auth_method | Anti-lockout — no password + this is the last linked provider |
401 | (no JWT or invalid) | Not authenticated |
429 | (throttle) | Rate limit exceeded (20 req/hour) |
Side effects
- Validate
provideragainst allowlist (['google', 'apple', 'x']). - Look up
SocialAccountrow for(userId, provider); verify exists. - Anti-lockout check: count user's
SocialAccountrows + checkUser.passwordHash. If unlinking would leave zero sign-in methods → reject400 only_auth_method. - Delete
SocialAccountrow. - Audit log:
auth.oauth.unlink.success. - Send notification email: "
{Provider}unlinked from your BIO.RE login on{date}".
Code samples
curl -X DELETE https://api.bio.re/api/v1/auth/oauth/unlink/google \
-H 'Authorization: Bearer <accessToken>'async function unlinkOAuth(provider: 'google' | 'apple' | 'x', accessToken: string): Promise<{ message: string }> {
const res = await fetch(`https://api.bio.re/api/v1/auth/oauth/unlink/${provider}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${accessToken}` },
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Unlink failed'), {
code: json?.error?.code,
});
}
return json.data;
}import { useMutation, useQueryClient } from '@tanstack/react-query';
import { meKeys } from './use-me';
export function useOAuthUnlink() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (provider: 'google' | 'apple' | 'x') => {
const res = await fetch(`/api/v1/auth/oauth/unlink/${provider}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${getAccessToken()}` },
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Unlink failed'), {
code: json?.error?.code,
i18nKey: json?.error?.i18nKey,
});
}
return json.data;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: meKeys.identity });
},
});
}Try it
Authorization
bearer In: header
Path Parameters
Response Body
application/json
application/json
application/json
application/json
curl -X DELETE "https://loading/api/v1/auth/oauth/unlink/string"{
"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"
}
}Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/auth/oauth.controller.ts | 92–110 (unlink) |
| Service | apps/api-core/src/modules/auth/oauth.service.ts | unlinkProvider() |
| Prisma model | packages/prisma/prisma/schema.prisma | SocialAccount, User.passwordHash |
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.
Send Phone OTP
Initiate a 6-digit phone OTP. Creates a Challenge record and dispatches an SMS code via the active SMS provider (admin-managed). Returns challengeId for verify/resend.