Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
f30ed2a
[Auth] Add extraFields and created
nezaj Mar 12, 2026
96a4ad5
Copy rules
nezaj Mar 12, 2026
f3e0356
Add Google OAuth redirect and natie test
nezaj Mar 12, 2026
75051af
sm
nezaj Mar 12, 2026
258659a
sm
nezaj Mar 12, 2026
1066018
Better tests
nezaj Mar 12, 2026
fb1f13f
sm
nezaj Mar 12, 2026
671e4bc
sm
nezaj Mar 12, 2026
9feb723
Enable e2e tests against multiple checkouts
nezaj Mar 12, 2026
11a6c33
Replace session storage with persistor
nezaj Mar 12, 2026
43f091e
sm lint rules
nezaj Mar 17, 2026
1f98849
Clear stale extraFields on OAuth flows without extraFields
nezaj Mar 17, 2026
3afcd28
verifyMagicCode -> consumeMagicCode
nezaj Mar 19, 2026
09a80b0
Add tests for create behavior
nezaj Mar 19, 2026
7483758
Implement auth check for create
nezaj Mar 19, 2026
4978874
Add perms for create to docs
nezaj Mar 20, 2026
5a6004b
Update rules with info on updating create rule
nezaj Mar 20, 2026
16f0e4d
Add sandbox test for extra fields
nezaj Mar 20, 2026
d67f7ca
restrict extra fields checks on signup only
nezaj Mar 20, 2026
5eb1105
sm docs
nezaj Mar 20, 2026
837e5cf
clojure lint
nezaj Mar 20, 2026
702a47d
cr
nezaj Mar 20, 2026
5b97942
Move e2e test to separate PR
nezaj Mar 20, 2026
cfc6d4f
rm un-nec check
nezaj Mar 20, 2026
9d40826
better types
nezaj Mar 20, 2026
18bbed3
create rule is allowed
nezaj Mar 20, 2026
ce4dc40
Add assert-signup
nezaj Mar 20, 2026
f4bdbec
consumeMagicCode -> checkMagicCode
nezaj Mar 20, 2026
3cb6d4b
lint
nezaj Mar 20, 2026
d949a22
Use perm pass
nezaj Mar 20, 2026
626b4f1
Store OAuth extraFields per nonce instead of single shared key
nezaj Mar 20, 2026
ed2f8fd
sm test
nezaj Mar 20, 2026
a6214f9
sm
nezaj Mar 20, 2026
bc18443
rm date
nezaj Mar 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 41 additions & 8 deletions client/packages/admin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ class Rooms<Schema extends InstantSchemaDef<any, any, any>> {
}
}

class Auth {
class Auth<Schema extends InstantSchemaDef<any, any, any>> {
config: FilledConfig;

constructor(config: FilledConfig) {
Expand Down Expand Up @@ -507,11 +507,8 @@ class Auth {
};

/**
* Verifies a magic code for the user with the given email.
*
* @example
* const user = await db.auth.verifyMagicCode({ email, code })
* console.log("Verified user:", user)
* @deprecated Use {@link checkMagicCode} instead to get the `created` field
* and support `extraFields`.
*
* @see https://instantdb.com/docs/backend#custom-magic-codes
*/
Expand All @@ -527,6 +524,42 @@ class Auth {
return user;
};

/**
* Verifies a magic code and returns the user along with whether
* the user was newly created. Supports `extraFields` to set custom
* `$users` properties at signup.
*
* @example
* const { user, created } = await db.auth.checkMagicCode(
* email,
* code,
* { extraFields: { nickname: 'ari' } },
* );
*
* @see https://instantdb.com/docs/backend#custom-magic-codes
*/
checkMagicCode = async (
email: string,
code: string,
options?: { extraFields?: UpdateParams<Schema, '$users'> },
): Promise<{ user: User; created: boolean }> => {
const res = await jsonFetch(
`${this.config.apiURI}/admin/verify_magic_code?app_id=${this.config.appId}`,
{
method: 'POST',
headers: authorizedHeaders(this.config),
body: JSON.stringify({
email,
code,
...(options?.extraFields
? { 'extra-fields': options.extraFields }
: {}),
}),
},
);
return { user: res.user, created: res.created };
};

/**
* Creates a login token for the user with the given email.
* If that user does not exist, we create one.
Expand Down Expand Up @@ -1067,7 +1100,7 @@ class InstantAdminDatabase<
>,
> {
config: InstantConfigFilled<Schema, UseDates>;
auth: Auth;
auth: Auth<Schema>;
storage: Storage;
streams: Streams;
rooms: Rooms<Schema>;
Expand All @@ -1082,7 +1115,7 @@ class InstantAdminDatabase<

constructor(_config: Config) {
this.config = instantConfigWithDefaults(_config);
this.auth = new Auth(this.config);
this.auth = new Auth<Schema>(this.config);
this.storage = new Storage(this.config, this.impersonationOpts);
this.streams = new Streams(this.#ensureInstantStream.bind(this));
this.rooms = new Rooms<Schema>(this.config);
Expand Down
34 changes: 23 additions & 11 deletions client/packages/core/__tests__/src/utils/e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,19 @@ import {
InstantSchemaDef,
} from '../../../src';

// @ts-ignore
const apiUrl = import.meta.env.VITE_INSTANT_DEV
? 'http://localhost:8888'
: // @ts-ignore
import.meta.env.VITE_INSTANT_API_URL || 'https://api.instantdb.com';
// __DEV_LOCAL_PORT__ is set by vitest.config.ts.
// This allows us to run tests against mulutple checkouts
// If CI=1 then __DEV_LOCAL_PORT__ will be falsey and tests will hit prod.
// Otherwise they will hit localhost at the specified port.
declare const __DEV_LOCAL_PORT__: number;

// @ts-ignore
const websocketURI = import.meta.env.VITE_INSTANT_DEV
? 'ws://localhost:8888/runtime/session'
: // @ts-ignore
import.meta.env.VITE_INSTANT_WEBSOCKET_URI ||
'wss://api.instantdb.com/runtime/session';
const apiUrl = __DEV_LOCAL_PORT__
? `http://localhost:${__DEV_LOCAL_PORT__}`
: 'https://api.instantdb.com';

const websocketURI = __DEV_LOCAL_PORT__
? `ws://localhost:${__DEV_LOCAL_PORT__}/runtime/session`
: 'wss://api.instantdb.com/runtime/session';

// Make a factory function that returns a typed test instance
export function makeE2ETest<Schema extends InstantSchemaDef<any, any, any>>({
Expand All @@ -31,6 +32,8 @@ export function makeE2ETest<Schema extends InstantSchemaDef<any, any, any>>({
}) {
return baseTest.extend<{
db: InstantCoreDatabase<Schema, false>;
appId: string;
adminToken: string;
}>({
db: async ({ task, signal }, use) => {
const response = await fetch(`${apiUrl}/dash/apps/ephemeral`, {
Expand All @@ -49,9 +52,18 @@ export function makeE2ETest<Schema extends InstantSchemaDef<any, any, any>>({
websocketURI,
schema,
});
(db as any)._testApp = app;
await use(db);
},
appId: async ({ db }, use) => {
await use((db as any)._testApp.id);
},
adminToken: async ({ db }, use) => {
await use((db as any)._testApp['admin-token']);
},
});
}

export { apiUrl };

export const e2eTest = makeE2ETest({});
1 change: 1 addition & 0 deletions client/packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"bench": "vitest bench",
"test:types": "tsc -p tsconfig.test.json --noEmit",
"test:ci": "vitest run && pnpm run test:types",
"test:e2e": "vitest run --project e2e",
"bench:ci": "vitest bench --run",
"check": "tsc --noEmit",
"check-exports": "attw --pack .",
Expand Down
60 changes: 49 additions & 11 deletions client/packages/core/src/Reactor.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ const defaultConfig = {

// Param that the backend adds if this is an oauth redirect
const OAUTH_REDIRECT_PARAM = '_instant_oauth_redirect';
const OAUTH_EXTRA_FIELDS_ID_PARAM = '_instant_extra_fields_id';

const oauthExtraFieldsKey = 'oauthExtraFields';

const currentUserKey = `currentUser`;

Expand Down Expand Up @@ -1877,6 +1880,7 @@ export default class Reactor {
if (url.searchParams.get(OAUTH_REDIRECT_PARAM)) {
const startUrl = url.toString();
url.searchParams.delete(OAUTH_REDIRECT_PARAM);
url.searchParams.delete(OAUTH_EXTRA_FIELDS_ID_PARAM);
url.searchParams.delete('code');
url.searchParams.delete('error');
const newPath =
Expand Down Expand Up @@ -1949,15 +1953,28 @@ export default class Reactor {
if (!code) {
return null;
}
const extraFieldsId = params.get(OAUTH_EXTRA_FIELDS_ID_PARAM);
this._replaceUrlAfterOAuth();
try {
let extraFields;
const stored = await this.kv.waitForKeyToLoad(oauthExtraFieldsKey);
if (extraFieldsId && stored) {
extraFields = stored[extraFieldsId];
}
// Clean up all stored extraFields after login
if (stored) {
this.kv.updateInPlace((prev) => {
delete prev[oauthExtraFieldsKey];
});
}
const currentUser = await this._getCurrentUser();
const isGuest = currentUser?.type === 'guest';
const { user } = await authAPI.exchangeCodeForToken({
apiURI: this.config.apiURI,
appId: this.config.appId,
code,
refreshToken: isGuest ? currentUser.refresh_token : undefined,
extraFields,
});
this.setCurrentUser(user);
return null;
Expand Down Expand Up @@ -2199,15 +2216,16 @@ export default class Reactor {
});
}

async signInWithMagicCode({ email, code }) {
async signInWithMagicCode(params) {
const currentUser = await this.getCurrentUser();
const isGuest = currentUser?.user?.type === 'guest';
const res = await authAPI.verifyMagicCode({
const res = await authAPI.checkMagicCode({
apiURI: this.config.apiURI,
appId: this.config.appId,
email,
code,
email: params.email,
code: params.code,
refreshToken: isGuest ? currentUser?.user?.refresh_token : undefined,
extraFields: params.extraFields,
});
await this.changeCurrentUser(res.user);
return res;
Expand Down Expand Up @@ -2266,19 +2284,36 @@ export default class Reactor {
* @param {Object} params - The parameters to create the authorization URL.
* @param {string} params.clientName - The name of the client requesting authorization.
* @param {string} params.redirectURL - The URL to redirect users to after authorization.
* @param {Record<string, any>} [params.extraFields] - Extra fields to write to $users on creation
* @returns {string} The created authorization URL.
*/
createAuthorizationURL({ clientName, redirectURL }) {
createAuthorizationURL({ clientName, redirectURL, extraFields }) {
const { apiURI, appId } = this.config;
return `${apiURI}/runtime/oauth/start?app_id=${appId}&client_name=${clientName}&redirect_uri=${redirectURL}`;
let finalRedirectURL = redirectURL;
if (extraFields) {
// Store extraFields under a unique ID so multiple
// createAuthorizationURL calls don't overwrite each other.
// The ID is passed through the redirect URL and used
// by _oauthLoginInit to retrieve the right extraFields.
// All entries are cleaned up after login.
const extraFieldsId = `${Math.random().toString(36).slice(2)}`;
this.kv.updateInPlace((prev) => {
const stored = prev[oauthExtraFieldsKey] || {};
stored[extraFieldsId] = extraFields;
prev[oauthExtraFieldsKey] = stored;
});
finalRedirectURL = `${redirectURL}${redirectURL.includes('?') ? '&' : '?'}${OAUTH_EXTRA_FIELDS_ID_PARAM}=${extraFieldsId}`;
}
return `${apiURI}/runtime/oauth/start?app_id=${appId}&client_name=${clientName}&redirect_uri=${encodeURIComponent(finalRedirectURL)}`;
}

/**
* @param {Object} params
* @param {string} params.code - The code received from the OAuth service.
* @param {string} [params.codeVerifier] - The code verifier used to generate the code challenge.
* @param {Record<string, any>} [params.extraFields] - Extra fields to write to $users on creation
*/
async exchangeCodeForToken({ code, codeVerifier }) {
async exchangeCodeForToken({ code, codeVerifier, extraFields }) {
const currentUser = await this.getCurrentUser();
const isGuest = currentUser?.user?.type === 'guest';
const res = await authAPI.exchangeCodeForToken({
Expand All @@ -2287,6 +2322,7 @@ export default class Reactor {
code: code,
codeVerifier,
refreshToken: isGuest ? currentUser?.user?.refresh_token : undefined,
extraFields,
});
await this.changeCurrentUser(res.user);
return res;
Expand All @@ -2302,18 +2338,20 @@ export default class Reactor {
* @param {string} params.clientName - The name of the client requesting authorization.
* @param {string} params.idToken - The id_token from the external service
* @param {string | null | undefined} [params.nonce] - The nonce used when requesting the id_token from the external service
* @param {Record<string, any>} [params.extraFields] - Extra fields to write to $users on creation
*/
async signInWithIdToken({ idToken, clientName, nonce }) {
async signInWithIdToken(params) {
const currentUser = await this.getCurrentUser();
const refreshToken = currentUser?.user?.refresh_token;

const res = await authAPI.signInWithIdToken({
apiURI: this.config.apiURI,
appId: this.config.appId,
idToken,
clientName,
nonce,
idToken: params.idToken,
clientName: params.clientName,
nonce: params.nonce,
refreshToken,
extraFields: params.extraFields,
});
await this.changeCurrentUser(res.user);
return res;
Expand Down
47 changes: 45 additions & 2 deletions client/packages/core/src/authAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export type VerifyMagicCodeParams = {
export type VerifyResponse = {
user: User;
};

/**
* @deprecated Use {@link checkMagicCode} instead to get the `created` field
* and support `extraFields`.
*/
export async function verifyMagicCode({
apiURI,
appId,
Expand All @@ -51,6 +56,38 @@ export async function verifyMagicCode({
return res;
}

export type CheckMagicCodeParams = {
email: string;
code: string;
refreshToken?: string | undefined;
extraFields?: Record<string, any> | undefined;
};
export type CheckMagicCodeResponse = {
user: User;
created: boolean;
};
export async function checkMagicCode({
apiURI,
appId,
email,
code,
refreshToken,
extraFields,
}: SharedInput & CheckMagicCodeParams): Promise<CheckMagicCodeResponse> {
const res = await jsonFetch(`${apiURI}/runtime/auth/verify_magic_code`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
'app-id': appId,
email,
code,
...(refreshToken ? { 'refresh-token': refreshToken } : {}),
...(extraFields ? { 'extra-fields': extraFields } : {}),
}),
});
return res;
}

export type VerifyRefreshTokenParams = { refreshToken: string };
export async function verifyRefreshToken({
apiURI,
Expand Down Expand Up @@ -86,6 +123,7 @@ export type ExchangeCodeForTokenParams = {
code: string;
codeVerifier?: string;
refreshToken?: string | undefined;
extraFields?: Record<string, any> | undefined;
};

export async function exchangeCodeForToken({
Expand All @@ -94,7 +132,8 @@ export async function exchangeCodeForToken({
code,
codeVerifier,
refreshToken,
}: SharedInput & ExchangeCodeForTokenParams): Promise<VerifyResponse> {
extraFields,
}: SharedInput & ExchangeCodeForTokenParams): Promise<CheckMagicCodeResponse> {
const res = await jsonFetch(`${apiURI}/runtime/oauth/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
Expand All @@ -103,6 +142,7 @@ export async function exchangeCodeForToken({
code: code,
code_verifier: codeVerifier,
refresh_token: refreshToken,
...(extraFields ? { extra_fields: extraFields } : {}),
}),
});
return res;
Expand All @@ -113,6 +153,7 @@ export type SignInWithIdTokenParams = {
idToken: string;
clientName: string;
refreshToken?: string;
extraFields?: Record<string, any> | undefined;
};

export async function signInWithIdToken({
Expand All @@ -122,7 +163,8 @@ export async function signInWithIdToken({
idToken,
clientName,
refreshToken,
}: SharedInput & SignInWithIdTokenParams): Promise<VerifyResponse> {
extraFields,
}: SharedInput & SignInWithIdTokenParams): Promise<CheckMagicCodeResponse> {
const res = await jsonFetch(`${apiURI}/runtime/oauth/id_token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
Expand All @@ -132,6 +174,7 @@ export async function signInWithIdToken({
id_token: idToken,
client_name: clientName,
refresh_token: refreshToken,
...(extraFields ? { extra_fields: extraFields } : {}),
}),
});
return res;
Expand Down
Loading
Loading