Skip to content

Commit 05b9276

Browse files
author
aXenDeveloper
committed
feat: Add sign in and auto link account to sso
1 parent bc34c73 commit 05b9276

38 files changed

Lines changed: 1339 additions & 71 deletions

‎apps/web/content/docs/dev/events/built-in-events.mdx‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ core event as for one of your own.
3838

3939
## Core events (`@vitnode/core`)
4040

41-
Six names, all declared in `VitNodeEvents` in
41+
Seven names, all declared in `VitNodeEvents` in
4242
`packages/vitnode/src/api/models/events.ts`. Every one of them fires **after**
4343
the write it describes has committed.
4444

@@ -47,6 +47,7 @@ the write it describes has committed.
4747
| `user.created` | `{ userId, email, name, emailVerified }` | A user row is inserted - public sign-up, AdminCP creation, or SSO sign-up |
4848
| `user.updated` | `{ userId, email, name }` | A user is edited in the AdminCP (profile fields and/or role assignments) |
4949
| `user.deleted` | `{ userId, email }` | Never - the name is declared for plugins, core has no deletion flow |
50+
| `user.sso.linked` | `{ userId, email, providerId }` | A visitor proves an existing account is theirs with its password and an SSO identity is linked to it |
5051
| `role.created` | `{ roleId }` | A role is created in the AdminCP |
5152
| `role.updated` | `{ roleId }` | A role is edited in the AdminCP |
5253
| `role.deleted` | `{ roleId }` | A role is deleted in the AdminCP |
@@ -74,7 +75,7 @@ AdminCP, and the first sign-in through an [SSO provider](/docs/dev/sso).
7475
},
7576
emailVerified: {
7677
description:
77-
'Whether the account starts verified. True for the very first user in an installation (the root account) and whenever no email adapter is configured, because nothing could send a verification mail.',
78+
'Whether the account starts verified. True for the very first user in an installation (the root account), whenever no email adapter is configured (nothing could send a verification mail), and for every account created through an SSO provider, which has already verified the address.',
7879
type: 'boolean',
7980
},
8081
}}
@@ -125,6 +126,35 @@ name, name code) and/or role assignments. The payload carries the user's
125126
list), invalidate a plugin-owned cache keyed by user, or audit-log the staff
126127
edit using the envelope's `actor`.
127128

129+
</Accordion>
130+
<Accordion title="user.sso.linked">
131+
132+
Emitted by `SSOModel.link` after the `core_users_sso` row is committed - the
133+
moment a social identity that arrived with an already-registered email is tied
134+
to that account. It does not fire on the first sign-in through a provider (that
135+
is a `user.created`) or on a later sign-in through an identity that is already
136+
linked.
137+
138+
<TypeTable
139+
type={{
140+
userId: {
141+
description: 'Id of the account the identity was linked to.',
142+
type: 'number',
143+
},
144+
email: {
145+
description: "The account's email - the address the provider and the account had in common.",
146+
type: 'string',
147+
},
148+
providerId: {
149+
description: "The adapter's id, e.g. 'google' or 'facebook'.",
150+
type: 'string',
151+
},
152+
}}
153+
/>
154+
155+
**A listener would** notify the account owner that a new sign-in method was
156+
added, or audit-log the link with the envelope's `actor`.
157+
128158
</Accordion>
129159
<Accordion title="user.deleted (declared, never emitted)">
130160

‎apps/web/content/docs/dev/sso/custom-adapter.mdx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ export const GitHubSSOApiPlugin = ({
6666
const emailRes = await fetch("https://api.github.com/user/emails", { headers })
6767
const emails: Array<{ email: string; primary: boolean; verified: boolean }> =
6868
await emailRes.json()
69-
const primaryEmail = emails.find((e) => e.primary && e.verified)?.email ?? user.email
69+
const primaryEmail = emails.find((e) => e.primary && e.verified)?.email
70+
if (!primaryEmail) throw new Error("GitHub account has no verified email")
7071

7172
return {
7273
id: String(user.id),
@@ -134,7 +135,7 @@ A **GitHub** login button automatically renders on `/login` and `/register`, and
134135
type: "(code: string) => Promise<{ access_token: string; token_type: string }>",
135136
},
136137
fetchUser: {
137-
description: "Fetches user profile (id, email, username, avatarUrl).",
138+
description: "Fetches user profile (id, email, username, avatarUrl). Return only an email the provider has verified - VitNode treats it as confirmed and will mark a matching unconfirmed account verified. The built-in adapters reject unverified addresses with a 400.",
138139
required: true,
139140
type: "(token: TokenResponse) => Promise<SSOUser>",
140141
},

‎apps/web/content/docs/dev/sso/index.mdx‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,39 @@ For example: `https://your-domain.com/login/sso/google`.
8181

8282
When a user signs in via SSO:
8383
1. **Known Identity**: The provider account was linked before, so the user is signed straight into it.
84-
2. **Existing Email Match**: An account already uses that address, so the request is refused with `409`. A social login can never take over an account someone else registered with a password.
84+
2. **Existing Email Match**: An account already uses that address. The visitor is asked for that account's password; once it checks out, the provider identity is linked to the account and they are signed in. From then on that provider signs them straight in (case 1). A social login can never take over an account on its own - the password is what proves ownership.
8585
3. **New Visitor**: A new user is created with their social display name, email, and avatar.
8686

87+
### SSO confirms the email
88+
89+
A provider only hands VitNode an address it has verified itself (the built-in
90+
adapters refuse anything else), so a social sign-in doubles as email
91+
confirmation:
92+
93+
- a **new visitor** starts out with `emailVerified: true`, even when an email
94+
adapter is configured and password sign-ups would have to confirm first;
95+
- a **known identity** or a **freshly linked** account whose address is still
96+
unconfirmed is marked confirmed - provided the address the provider returned
97+
is the one on the account. An account whose email was changed since the link
98+
was made is left alone.
99+
100+
### Linking an existing account
101+
102+
The callback answers `409` with a short-lived, signed **link offer** - the
103+
account's email, whether it has a password, and a token good for ten minutes.
104+
The login page turns that into a "Connect *Provider* to your account" form: the
105+
email is shown read-only, the visitor types their password, and
106+
`POST /users/sso/{providerId}/link` verifies both before it writes the
107+
`core_users_sso` row and mints a session. The token carries the provider account
108+
id, so nothing about *which* identity gets linked is taken from the browser.
109+
110+
An account created by another provider has no password to confirm with. When an
111+
email adapter is configured the form offers **Set a password** (the ordinary
112+
reset flow) and asks them to try the provider again afterwards; without one it
113+
points back to the login page.
114+
115+
Every successful link emits [`user.sso.linked`](/docs/dev/events/built-in-events).
116+
87117
The address the provider returns is matched in its canonical form, so a Google account that reports `jan.kowalski@gmail.com` finds the member who registered as `jankowalski@gmail.com` instead of quietly becoming a second account. See [One Mailbox, One Account](/docs/dev/advanced/auth#one-mailbox-one-account).
88118

89119
## Learn More

‎apps/web/src/lib/auth.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import {
77
signOutInputSchema,
88
signUpInputSchema,
99
ssoCallbackInputSchema,
10+
ssoLinkInputSchema,
1011
ssoStartInputSchema,
1112
} from '@vitnode/core/tanstack/auth'
1213
import {
1314
changePasswordFromResetOnApi,
1415
completeSsoOnApi,
16+
linkSsoOnApi,
1517
readSessionOnApi,
1618
requestPasswordResetOnApi,
1719
signInOnApi,
@@ -40,6 +42,10 @@ export const completeSsoFn = createServerFn({ method: 'POST' })
4042
.validator(ssoCallbackInputSchema)
4143
.handler(async ({ data }) => await completeSsoOnApi(data))
4244

45+
export const linkSsoFn = createServerFn({ method: 'POST' })
46+
.validator(ssoLinkInputSchema)
47+
.handler(async ({ data }) => await linkSsoOnApi(data))
48+
4349
export const signUpFn = createServerFn({ method: 'POST' })
4450
.validator(signUpInputSchema)
4551
.handler(async ({ data }) => await signUpOnApi(data))
@@ -56,6 +62,7 @@ setAuthTransport({
5662
changePasswordFromReset: async (input) =>
5763
await changePasswordFromResetFn({ data: input }),
5864
completeSso: async (input) => await completeSsoFn({ data: input }),
65+
linkSso: async (input) => await linkSsoFn({ data: input }),
5966
readSession: async () => await readSessionFn(),
6067
requestPasswordReset: async (input) =>
6168
await requestPasswordResetFn({ data: input }),

‎packages/create-vitnode-app/copy-of-vitnode-app/root/src/lib/auth.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import {
77
signOutInputSchema,
88
signUpInputSchema,
99
ssoCallbackInputSchema,
10+
ssoLinkInputSchema,
1011
ssoStartInputSchema,
1112
} from "@vitnode/core/tanstack/auth";
1213
import {
1314
changePasswordFromResetOnApi,
1415
completeSsoOnApi,
16+
linkSsoOnApi,
1517
readSessionOnApi,
1618
requestPasswordResetOnApi,
1719
signInOnApi,
@@ -40,6 +42,10 @@ export const completeSsoFn = createServerFn({ method: "POST" })
4042
.validator(ssoCallbackInputSchema)
4143
.handler(async ({ data }) => await completeSsoOnApi(data));
4244

45+
export const linkSsoFn = createServerFn({ method: "POST" })
46+
.validator(ssoLinkInputSchema)
47+
.handler(async ({ data }) => await linkSsoOnApi(data));
48+
4349
export const signUpFn = createServerFn({ method: "POST" })
4450
.validator(signUpInputSchema)
4551
.handler(async ({ data }) => await signUpOnApi(data));
@@ -56,6 +62,7 @@ setAuthTransport({
5662
changePasswordFromReset: async input =>
5763
await changePasswordFromResetFn({ data: input }),
5864
completeSso: async input => await completeSsoFn({ data: input }),
65+
linkSso: async input => await linkSsoFn({ data: input }),
5966
readSession: async () => await readSessionFn(),
6067
requestPasswordReset: async input =>
6168
await requestPasswordResetFn({ data: input }),
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { Context } from "hono";
2+
3+
import { eq } from "drizzle-orm";
4+
import crypto from "node:crypto";
5+
6+
import { core_secrets } from "@/database/secrets";
7+
8+
type SecretsDatabase = Omit<Context["var"]["db"], "$client">;
9+
10+
const KEY_BYTES = 32;
11+
12+
const generate = (): string => crypto.randomBytes(KEY_BYTES).toString("base64");
13+
14+
const read = async (
15+
db: SecretsDatabase,
16+
name: string,
17+
): Promise<string | undefined> => {
18+
const [row] = await db
19+
.select({ value: core_secrets.value })
20+
.from(core_secrets)
21+
.where(eq(core_secrets.name, name))
22+
.limit(1);
23+
24+
return row?.value;
25+
};
26+
27+
const readOrCreate = async (
28+
db: SecretsDatabase,
29+
name: string,
30+
): Promise<string> => {
31+
const existing = await read(db, name);
32+
if (existing !== undefined) return existing;
33+
34+
const [inserted] = await db
35+
.insert(core_secrets)
36+
.values({ name, value: generate() })
37+
.onConflictDoNothing()
38+
.returning({ value: core_secrets.value });
39+
if (inserted) return inserted.value;
40+
41+
const winner = await read(db, name);
42+
if (winner !== undefined) return winner;
43+
44+
throw new Error(`Could not read or create the "${name}" server secret.`);
45+
};
46+
47+
const cached = new Map<string, Promise<string>>();
48+
49+
export const ensureServerSecret = async (
50+
db: SecretsDatabase,
51+
name: string,
52+
): Promise<string> => {
53+
const pending =
54+
cached.get(name) ??
55+
readOrCreate(db, name).catch((error: unknown) => {
56+
cached.delete(name);
57+
throw error;
58+
});
59+
cached.set(name, pending);
60+
61+
return await pending;
62+
};
63+
64+
export const resetServerSecrets = (): void => {
65+
cached.clear();
66+
};
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { ssoConfirmsEmail } from "./sso-email-confirmation";
4+
5+
describe("whether an SSO sign-in confirms the account's email", () => {
6+
it("confirms an unverified address the provider just vouched for", () => {
7+
expect(
8+
ssoConfirmsEmail({
9+
accountEmail: "jan@example.com",
10+
emailVerified: false,
11+
providerEmail: "jan@example.com",
12+
}),
13+
).toBe(true);
14+
});
15+
16+
it("matches the address the way sign-in does, canonical form included", () => {
17+
expect(
18+
ssoConfirmsEmail({
19+
accountEmail: "jankowalski@gmail.com",
20+
emailVerified: false,
21+
providerEmail: "Jan.Kowalski@gmail.com",
22+
}),
23+
).toBe(true);
24+
});
25+
26+
it("leaves a verified account alone", () => {
27+
expect(
28+
ssoConfirmsEmail({
29+
accountEmail: "jan@example.com",
30+
emailVerified: true,
31+
providerEmail: "jan@example.com",
32+
}),
33+
).toBe(false);
34+
});
35+
36+
it("does not confirm an address the provider did not return", () => {
37+
expect(
38+
ssoConfirmsEmail({
39+
accountEmail: "old@example.com",
40+
emailVerified: false,
41+
providerEmail: "new@example.com",
42+
}),
43+
).toBe(false);
44+
});
45+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { emailAliases } from "./user-email-lookup";
2+
3+
export const ssoConfirmsEmail = ({
4+
accountEmail,
5+
emailVerified,
6+
providerEmail,
7+
}: {
8+
accountEmail: string;
9+
emailVerified: boolean;
10+
providerEmail: string;
11+
}): boolean =>
12+
!emailVerified && emailAliases(providerEmail).includes(accountEmail);

‎packages/vitnode/src/api/models/events.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ export interface VitNodeEvents {
2828
email: string;
2929
userId: number;
3030
};
31+
"user.sso.linked": {
32+
email: string;
33+
providerId: string;
34+
userId: number;
35+
};
3136
"user.updated": {
3237
email: string;
3338
name: string;

0 commit comments

Comments
 (0)