Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions PACKAGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,18 @@ permission disappears. Provider-only accounts stay provider-only even if the
provider later supplies an email; adopting an address or merging existing accounts
requires a separate account-recovery flow. Without a common email or an existing
binding, different providers cannot be matched automatically.

`rememberLoginMethod: true` enables Better Auth's last-login-method plugin with
no database field. A successful login sets a readable 30-day cookie containing
only `email` or a provider ID. Failed attempts and explicit links do not update
it; logout retains it. The name is `__Host-pgstencil.last_login_method` on HTTPS
and `pgstencil.last_login_method` locally. This is an untrusted display hint,
never proof of identity or a replacement for a session. New browsers/private
windows and cleared cookies have no hint.

The Better Auth integration also supports `microsoft` with `MICROSOFT_CLIENT_ID`
and `MICROSOFT_CLIENT_SECRET`. Its callback is `/api/auth/callback/microsoft`.
Personal and work/school accounts are supported; verified email can participate
in same-email linking. Unverified email does not establish an account match.
[Microsoft configuration and identity checks](examples/better-auth/README.md#microsoft)
include the optional ID-token claims needed for email matching.
27 changes: 24 additions & 3 deletions examples/better-auth/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Better Auth with pgstencil

Better Auth 1.7.3 handles email-code login and Google, Apple, Facebook and GitHub
Better Auth 1.7.3 handles email-code login and Google, Apple, Facebook, GitHub and Microsoft
OAuth. pgstencil owns SQL migrations, Docker/IntegreSQL clones, email capture,
security policy and deterministic tests. The reusable exports live in `@pgstencil/auth`; see [package consumption](../../PACKAGES.md#better-auth-integration). This example is isolated from the old
auth implementation and from TTR production.
Expand Down Expand Up @@ -53,7 +53,7 @@ and/or `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in the process environment.
replay claim. Apple form_post relays to a GET that receives the Lax cookies.
Callback destinations are fixed to the application origin. Direct provider-token
sign-in and unused upstream auth endpoints are unavailable.
- The pinned version's Google/Apple redirect profile readers only decode ID tokens.
- The pinned version's Google/Apple/Microsoft redirect profile readers only decode ID tokens.
`verifiedOidc` explicitly enables Better Auth's signature/issuer/audience/expiry/
nonce verification through its plugin API. Negative tests cover each check.
GitHub requires a verified primary email; Facebook uses the authenticated email
Expand All @@ -71,7 +71,7 @@ and outbound fetch facades use AsyncLocalStorage to select each app's clock,
random stream and local provider server. They do not replace process globals,
cryptographic hashing/signing or timers. Normal builds have no injection or test
clock routes. Repeatable email/OAuth cookies, sessions and timestamps are tested
across parallel apps; all four providers also run through real workerd.
across parallel apps; all five providers also run through real workerd.

This adapter covers these APIs in the bundled dependency graph. Dependency
upgrades must rerun security and snapshot tests. The same request order is
Expand Down Expand Up @@ -106,3 +106,24 @@ Enter secrets directly into deployment tooling, not chat, source files or logs.
Do not delete Supabase/Pages until email and each required provider pass in a real
browser. The TTR test must also confirm that a second device's login signs out
the first, and that its chosen linking policy preserves account IDs across login methods.

## Microsoft

The Better Auth adapter supports personal and work/school Microsoft accounts at
`https://login.microsoftonline.com/common`. Configure `MICROSOFT_CLIENT_ID` and
`MICROSOFT_CLIENT_SECRET`, with a Web redirect URI of
`https://<origin>/api/auth/callback/microsoft`. It requests only OpenID, profile,
and email scopes; there is no Graph photo request or offline access.

Signatures, audience, expiration, nonce and the tenant-specific issuer are checked
before reading claims. Stable identities include both `tid` and `oid`.
An email can join an existing account only with `email_verified: true`,
`xms_edov: true`, or membership in the verified email claims. Ordinary `email`
and `preferred_username` are insufficient. Request `email` and `xms_edov` as
optional ID-token claims in the app registration. With `allowMissingEmail`,
missing/unverified email becomes a provider-only account without a code prompt.
`trustedEmailProviders: ['microsoft']` bypasses the additional local mailbox proof
only after these Microsoft-specific verification checks pass.

See [Microsoft's claim reference](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference)
and [Better Auth's provider setup](https://better-auth.com/docs/authentication/microsoft).
2 changes: 1 addition & 1 deletion examples/better-auth/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const loginScript = `
const send = document.querySelector('#send'), verify = document.querySelector('#verify');
const status = document.querySelector('#status'), logout = document.querySelector('#logout');
let csrf, signedIn = false;
const providerNames = {google:'Google', apple:'Apple', facebook:'Facebook', github:'GitHub'};
const providerNames = {google:'Google', apple:'Apple', facebook:'Facebook', github:'GitHub', microsoft:'Microsoft'};
const enabledProviders = await (await fetch('/api/providers')).json();
const showProviders = async () => {
const container = document.querySelector('#providers'); container.replaceChildren();
Expand Down
18 changes: 18 additions & 0 deletions packages/auth/src/better-auth-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,21 @@ export function identityEmail(
.digest('hex') + identityDomain
);
}

/** Microsoft object IDs are tenant-scoped. Never substitute mutable email/UPN. */
export function providerSubject(
provider: string,
profile: Record<string, unknown> | undefined,
) {
if (provider !== 'microsoft') return profile?.sub ?? profile?.id;
const uuid =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (
typeof profile?.tid !== 'string' ||
!uuid.test(profile.tid) ||
typeof profile.oid !== 'string' ||
!uuid.test(profile.oid)
)
throw new Error('Invalid Microsoft identity');
return `${profile.tid.toLowerCase()}:${profile.oid.toLowerCase()}`;
}
99 changes: 88 additions & 11 deletions packages/auth/src/better-auth-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,65 @@ import type { GithubProfile } from 'better-auth/social-providers';
import { makeSignature } from 'better-auth/crypto';
import { sql } from 'kysely';
import { equal, keyed } from './better-auth-security.ts';
import { identityEmail, isIdentityEmail } from './better-auth-email.ts';
import {
identityEmail,
isIdentityEmail,
providerSubject,
} from './better-auth-email.ts';
import type { connectDatabase } from 'pgstencil/postgres';

export const providers = ['google', 'apple', 'facebook', 'github'] as const;
export const providers = [
'google',
'apple',
'facebook',
'github',
'microsoft',
] as const;
export type Provider = (typeof providers)[number];
export type OAuthSettings = Partial<
Record<Provider, { clientId: string; clientSecret: string }>
>;

/** Pin the redirect flow's OIDC checks explicitly on Better Auth 1.7.3.
* Its Google/Apple provider metadata supports verification, but their default
* Its Google/Apple/Microsoft provider metadata supports verification, but their default
* redirect getUserInfo only decodes claims. Use the library's actual verifier.
*/
export const verifiedOidc: BetterAuthPlugin = {
id: 'pgstencil-verified-oidc',
init(context) {
for (const provider of context.socialProviders) {
if (provider.id !== 'google' && provider.id !== 'apple') continue;
if (!['google', 'apple', 'microsoft'].includes(provider.id)) continue;
provider.requiresIdTokenNonce = true;
provider.issuer =
provider.id === 'google'
? 'https://accounts.google.com'
: 'https://appleid.apple.com';
if (provider.id !== 'microsoft')
provider.issuer =
provider.id === 'google'
? 'https://accounts.google.com'
: 'https://appleid.apple.com';
if (provider.idToken && 'jwks' in provider.idToken)
provider.idToken.algorithms = ['RS256'];
if (provider.id === 'microsoft') {
provider.accountSubject = ({ profile }) =>
String(
providerSubject('microsoft', profile as Record<string, unknown>),
);
if (provider.idToken && 'jwks' in provider.idToken) {
const verifyClaims = provider.idToken.verifyClaims;
provider.idToken.verifyClaims = (claims) => {
try {
providerSubject('microsoft', claims);
} catch {
return false;
}
return verifyClaims?.(claims) === true;
};
}
}
const authorization = provider.createAuthorizationURL.bind(provider);
provider.createAuthorizationURL = async (data) => {
if (!data.idTokenNonce) throw new Error('Missing OIDC nonce');
const url = await authorization(data);
url.searchParams.set('nonce', data.idTokenNonce);
if (provider.id === 'google')
if (provider.id === 'google' || provider.id === 'microsoft')
url.searchParams.set('prompt', 'select_account');
return url;
};
Expand Down Expand Up @@ -78,7 +106,13 @@ export function socialProviders(
): BetterAuthOptions['socialProviders'] {
const missingEmail = (
provider: Provider,
profile: { email?: string | null; sub?: string; id?: string | number },
profile: {
email?: string | null;
sub?: string;
id?: string | number;
tid?: string;
oid?: string;
},
) => {
if (profile.email) {
if (isIdentityEmail(profile.email))
Expand All @@ -90,7 +124,7 @@ export function socialProviders(
email: identityEmail(
provider,
settings[provider]!.clientId,
profile.sub ?? profile.id,
providerSubject(provider, profile),
),
emailVerified: false,
}
Expand All @@ -115,6 +149,49 @@ export function socialProviders(
},
}
: {}),
...(settings.microsoft
? {
microsoft: {
...settings.microsoft,
tenantId: 'common',
disableProfilePhoto: true,
disableDefaultScope: true,
scope: ['openid', 'profile', 'email'],
mapProfileToUser: async (profile) => {
const email = profile.email;
const verified =
profile.email_verified === true ||
profile.xms_edov === true ||
[
profile.verified_primary_email,
profile.verified_secondary_email,
].some(
(values) =>
Array.isArray(values) &&
values.some(
(value) =>
typeof value === 'string' &&
value.toLowerCase() === email?.toLowerCase(),
),
);
// A tenant admin can edit ordinary email/UPN. Only verified claims may
// join an existing email account; otherwise allow provider-only signup.
return {
name:
typeof profile.name === 'string'
? profile.name
: 'Microsoft user',
email: verified ? (email ?? null) : null,
emailVerified: !!email && verified,
...missingEmail('microsoft', {
...profile,
email: verified ? (email ?? null) : null,
}),
};
},
},
}
: {}),
...(settings.facebook
? {
facebook: {
Expand Down
3 changes: 2 additions & 1 deletion packages/auth/src/better-auth-security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ export function protectAuth(
const path = c.req.path.slice('/api/auth'.length);
if (path === '/link-social' && options.accountLinking === 'same-email')
return c.json({ message: 'Not found' }, 404);
const callback = /^\/callback\/(google|github|apple|facebook)$/.test(path);
const callback =
/^\/callback\/(google|github|apple|facebook|microsoft)$/.test(path);
const reads = ['/get-session', '/list-accounts'];
const writes = [
'/email-otp/send-verification-otp',
Expand Down
1 change: 1 addition & 0 deletions packages/auth/src/better-auth-workers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function createBetterAuthWorker<E extends BetterAuthWorkerBindings>(
| 'accountLinking'
| 'trustedEmailProviders'
| 'allowMissingEmail'
| 'rememberLoginMethod'
| 'appName'
| 'successPath'
| 'errorPath'
Expand Down
25 changes: 23 additions & 2 deletions packages/auth/src/better-auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { betterAuth, type BetterAuthOptions } from 'better-auth';
import { getSessionFromCtx } from 'better-auth/api';
import { lastLoginMethod } from 'better-auth/plugins';
import { emailOTP } from 'better-auth/plugins/email-otp';
import { Hono } from 'hono';
import { sql } from 'kysely';
Expand All @@ -18,7 +19,11 @@ import {
type Provider,
} from './better-auth-oauth.ts';

import { identityEmail, isIdentityEmail } from './better-auth-email.ts';
import {
identityEmail,
isIdentityEmail,
providerSubject,
} from './better-auth-email.ts';

export interface AuthOptions {
database: ReturnType<typeof connectDatabase>;
Expand All @@ -32,6 +37,8 @@ export interface AuthOptions {
trustedEmailProviders?: Provider[];
/** Permit provider-only accounts; their public session email is null. */
allowMissingEmail?: boolean;
/** Remember the last successful method in a readable, non-authenticating 30-day cookie. */
rememberLoginMethod?: boolean;
oauth?: OAuthSettings;
appName?: string;
successPath?: string;
Expand Down Expand Up @@ -80,7 +87,7 @@ export function authOptions(options: AuthOptions): BetterAuthOptions {
identityEmail(
provider,
options.oauth[provider].clientId,
profile?.sub ?? profile?.id,
providerSubject(provider, profile),
)
)
return;
Expand Down Expand Up @@ -216,6 +223,20 @@ export function authOptions(options: AuthOptions): BetterAuthOptions {
},
plugins: [
verifiedOidc,
...(options.rememberLoginMethod
? [
lastLoginMethod({
cookieName: secure
? '__Host-pgstencil.last_login_method'
: 'pgstencil.last_login_method',
storeInDatabase: false,
customResolveMethod: (context) =>
context.path === '/sign-in/email-otp' ? 'email' : null,
// Do not record failed callbacks, cookie clearing, or explicit linking.
beforeStoreCookie: (context) => !!context.context.newSession,
}),
]
: []),
emailOTP({
otpLength: 8,
expiresIn: 600,
Expand Down
Loading
Loading