Skip to content

core: oauth: allow multiple ids#1142

Merged
undefined-moe merged 1 commit intomasterfrom
oauth-multiple
Apr 8, 2026
Merged

core: oauth: allow multiple ids#1142
undefined-moe merged 1 commit intomasterfrom
oauth-multiple

Conversation

@undefined-moe
Copy link
Copy Markdown
Member

@undefined-moe undefined-moe commented Apr 2, 2026

Summary by CodeRabbit

  • New Features

    • OAuth authentication now supports providers returning multiple identifiers, expanding compatibility with diverse authentication services.
  • Bug Fixes

    • Enhanced OAuth binding validation to prevent conflicts and ensure accurate account linkage across different providers.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 2, 2026

Walkthrough

This PR extends OAuth callback handling to support OAuth providers that return multiple identifier values for a single user. The implementation normalizes single or multiple _id values from provider responses into an array format, performs parallel binding lookups for all candidate identifiers, and ensures consistent account resolution across multiple provider-supplied ids. The type signature for OAuthUserResponse._id is updated to accept either a string or array of strings, with corresponding handler logic updated to validate and manage bindings for all candidate identifiers.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • hydro-dev/Hydro#1110: Modifies the same OAuth callback binding flow in packages/hydrooj/src/handler/user.ts to handle provider id and binding resolution, though addressing email-based binding scenarios.
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'core: oauth: allow multiple ids' accurately and concisely describes the main change: extending OAuth ID handling to support multiple values.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch oauth-multiple

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/hydrooj/src/handler/user.ts (1)

470-526: ⚠️ Potential issue | 🟠 Major

Carry the normalized id array through deferred registration.

Line 525 still stores raw r._id in the registration token, while Line 323 later forwards this.tdoc.identity.id into a single oauth.set(...) call. For multi-id providers, that makes the registration path persist a different shape than the direct bind/email paths, and OauthMap.id can end up as string[] even though the model/API still assumes string.

🧩 Suggested follow-up
-        const ids = Array.isArray(r._id) ? r._id : [r._id];
+        const ids = [...new Set(Array.isArray(r._id) ? r._id : [r._id])];
...
                 identity: {
                     provider: args.type,
                     platform: args.type,
-                    id: r._id,
+                    id: ids,
                 },

Then mirror the same normalization in UserRegisterWithCodeHandler before writing bindings:

const ids = Array.isArray(this.tdoc.identity.id) ? this.tdoc.identity.id : [this.tdoc.identity.id];
await Promise.all(ids.map((id) => this.ctx.oauth.set(this.tdoc.identity.platform, id, uid)));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/hydrooj/src/handler/user.ts` around lines 470 - 526, The
registration token is storing raw r._id (possibly a string) which causes
inconsistent shapes for identity.id when providers return multiple ids;
normalize into an array before adding the token and mirror the same
normalization when consuming the token. Change the token creation in the
registration flow (where token.add is called) to compute ids =
Array.isArray(r._id) ? r._id : [r._id] and put that normalized ids into the
identity.id field instead of raw r._id, and update UserRegisterWithCodeHandler
(before calling this.ctx.oauth.set) to normalize this.tdoc.identity.id the same
way (const ids = Array.isArray(this.tdoc.identity.id) ? this.tdoc.identity.id :
[this.tdoc.identity.id]; then await Promise.all(ids.map(id =>
this.ctx.oauth.set(this.tdoc.identity.platform, id, uid)))) so oauth.set always
receives individual string ids.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/hydrooj/src/handler/user.ts`:
- Around line 470-485: The code currently picks the first truthy UID from
existing and logs in that account; instead, ensure all non-null entries in
existing refer to the same UID before calling successfulAuth. Change the logic
around existing/effective: collect unique non-null UIDs from existing (from
this.ctx.oauth.get results), if more than one throw BadRequestError('OAuth
identities already bound to different accounts'), if exactly one use that UID as
effective and call successfulAuth(await user.getById('system', effective)),
otherwise proceed as before; keep the oauth set flow (this.ctx.oauth.set)
unchanged.

---

Outside diff comments:
In `@packages/hydrooj/src/handler/user.ts`:
- Around line 470-526: The registration token is storing raw r._id (possibly a
string) which causes inconsistent shapes for identity.id when providers return
multiple ids; normalize into an array before adding the token and mirror the
same normalization when consuming the token. Change the token creation in the
registration flow (where token.add is called) to compute ids =
Array.isArray(r._id) ? r._id : [r._id] and put that normalized ids into the
identity.id field instead of raw r._id, and update UserRegisterWithCodeHandler
(before calling this.ctx.oauth.set) to normalize this.tdoc.identity.id the same
way (const ids = Array.isArray(this.tdoc.identity.id) ? this.tdoc.identity.id :
[this.tdoc.identity.id]; then await Promise.all(ids.map(id =>
this.ctx.oauth.set(this.tdoc.identity.platform, id, uid)))) so oauth.set always
receives individual string ids.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7b52cc84-edea-469f-ab0a-a1b1ae3e4a9c

📥 Commits

Reviewing files that changed from the base of the PR and between 642662f and f889ed1.

📒 Files selected for processing (2)
  • packages/hydrooj/src/handler/user.ts
  • packages/hydrooj/src/model/oauth.ts

Comment on lines +470 to 485
const ids = Array.isArray(r._id) ? r._id : [r._id];
const existing = await Promise.all(ids.map((id) => this.ctx.oauth.get(args.type, id)));
if (this.session.oauthBind === args.type) {
delete this.session.oauthBind;
const existing = await this.ctx.oauth.get(args.type, r._id);
if (existing && existing !== this.user._id) {
if (existing.some((id) => id && id !== this.user._id)) {
throw new BadRequestError('Already binded to another account');
}
this.response.redirect = '/home/security';
if (existing !== this.user._id) await this.ctx.oauth.set(args.type, r._id, this.user._id);
await Promise.all(ids.map((i) => this.ctx.oauth.set(args.type, i, this.user._id)));
return;
}
const effective = existing.find((i) => i);

const uid = await this.ctx.oauth.get(args.type, r._id);
if (uid) {
await successfulAuth.call(this, await user.getById('system', uid));
if (effective) {
await successfulAuth.call(this, await user.getById('system', effective));
this.response.redirect = '/';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fail closed when the candidate ids map to different users.

Line 481 picks the first truthy uid from existing. If two returned ids are already bound to different Hydro accounts, provider order decides which account gets logged in. Require all non-null bindings to collapse to a single uid before calling successfulAuth.

🔒 Suggested guard
         if (this.session.oauthBind === args.type) {
             delete this.session.oauthBind;
-            if (existing.some((id) => id && id !== this.user._id)) {
+            if (existing.some((uid) => uid !== null && uid !== this.user._id)) {
                 throw new BadRequestError('Already binded to another account');
             }
             this.response.redirect = '/home/security';
             await Promise.all(ids.map((i) => this.ctx.oauth.set(args.type, i, this.user._id)));
             return;
         }
-        const effective = existing.find((i) => i);
+        const boundUids = [...new Set(existing.filter((uid): uid is number => uid !== null))];
+        if (boundUids.length > 1) {
+            throw new BadRequestError('OAuth ids are bound to multiple accounts');
+        }
+        const effective = boundUids[0];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const ids = Array.isArray(r._id) ? r._id : [r._id];
const existing = await Promise.all(ids.map((id) => this.ctx.oauth.get(args.type, id)));
if (this.session.oauthBind === args.type) {
delete this.session.oauthBind;
const existing = await this.ctx.oauth.get(args.type, r._id);
if (existing && existing !== this.user._id) {
if (existing.some((id) => id && id !== this.user._id)) {
throw new BadRequestError('Already binded to another account');
}
this.response.redirect = '/home/security';
if (existing !== this.user._id) await this.ctx.oauth.set(args.type, r._id, this.user._id);
await Promise.all(ids.map((i) => this.ctx.oauth.set(args.type, i, this.user._id)));
return;
}
const effective = existing.find((i) => i);
const uid = await this.ctx.oauth.get(args.type, r._id);
if (uid) {
await successfulAuth.call(this, await user.getById('system', uid));
if (effective) {
await successfulAuth.call(this, await user.getById('system', effective));
this.response.redirect = '/';
const ids = Array.isArray(r._id) ? r._id : [r._id];
const existing = await Promise.all(ids.map((id) => this.ctx.oauth.get(args.type, id)));
if (this.session.oauthBind === args.type) {
delete this.session.oauthBind;
if (existing.some((uid) => uid !== null && uid !== this.user._id)) {
throw new BadRequestError('Already binded to another account');
}
this.response.redirect = '/home/security';
await Promise.all(ids.map((i) => this.ctx.oauth.set(args.type, i, this.user._id)));
return;
}
const boundUids = [...new Set(existing.filter((uid): uid is number => uid !== null))];
if (boundUids.length > 1) {
throw new BadRequestError('OAuth ids are bound to multiple accounts');
}
const effective = boundUids[0];
if (effective) {
await successfulAuth.call(this, await user.getById('system', effective));
this.response.redirect = '/';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/hydrooj/src/handler/user.ts` around lines 470 - 485, The code
currently picks the first truthy UID from existing and logs in that account;
instead, ensure all non-null entries in existing refer to the same UID before
calling successfulAuth. Change the logic around existing/effective: collect
unique non-null UIDs from existing (from this.ctx.oauth.get results), if more
than one throw BadRequestError('OAuth identities already bound to different
accounts'), if exactly one use that UID as effective and call
successfulAuth(await user.getById('system', effective)), otherwise proceed as
before; keep the oauth set flow (this.ctx.oauth.set) unchanged.

@undefined-moe undefined-moe merged commit 4f8c9e7 into master Apr 8, 2026
7 checks passed
@undefined-moe undefined-moe deleted the oauth-multiple branch April 8, 2026 15:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant