Skip to content

Add Google authentication with OpenID Connect and PKCE#840

Merged
tjementum merged 13 commits intomainfrom
google-authentication
Feb 10, 2026
Merged

Add Google authentication with OpenID Connect and PKCE#840
tjementum merged 13 commits intomainfrom
google-authentication

Conversation

@tjementum
Copy link
Member

@tjementum tjementum commented Feb 10, 2026

Summary & Motivation

Add external authentication via Google OAuth using OpenID Connect (OIDC) with PKCE, enabling users to sign up and log in with their Google account. The implementation is designed as a provider-agnostic framework that can be extended with additional identity providers (Microsoft Entra ID, MitID/Criipto, OKTA, etc.) by implementing the IOAuthProvider interface.

Significant effort has gone into security hardening, domain modeling, infrastructure automation, frontend UX, and comprehensive test coverage.

Domain model and authentication flow changes:

  • Rename Login aggregate to EmailLogin and consolidate EmailConfirmation into it, creating a cleaner separation between email-based OTP authentication and external provider authentication
  • Add ExternalLogin aggregate to track OIDC flows from authorization redirect through callback completion, storing PKCE code verifier, encrypted state token, browser fingerprint, and nonce
  • Add ExternalIdentities JSON column on User entity to support linking multiple external providers per user (Google, Entra ID, etc.)
  • Add LoginMethod to Session entity to track whether a session was created via email OTP or an external provider
  • Restructure API endpoints: email authentication moves to /authentication/email/ and external authentication uses /authentication/{provider}/

Provider abstraction (IOAuthProvider):

  • BuildAuthorizationUrl() -- construct the OIDC authorization URL with PKCE challenge and nonce
  • ExchangeCodeForTokensAsync() -- exchange authorization code for tokens using PKCE verifier
  • ValidateIdTokenAsync() -- validate JWT signature, audience, issuer, nonce, and at_hash
  • MapClaims() -- extract user profile from claims with provider-specific mapping
  • GoogleOAuthProvider implements this interface; a MockOAuthProvider enables E2E testing without real Google credentials

Security measures implemented:

  • PKCE (RFC 7636) prevents authorization code interception
  • State token encryption with Data Protection API for CSRF protection
  • Browser fingerprint validation (SHA256 of User-Agent + Accept-Language) for session hijacking detection
  • 5-minute flow expiration window with single-use constraint to prevent replay attacks
  • OIDC nonce validation on ID tokens
  • at_hash claim validation for access token binding
  • azp (authorized party) claim validation for defense-in-depth
  • JWT algorithm pinning to prevent algorithm confusion attacks
  • Open redirect prevention on return paths with URL encoding bypass protection (both backend ReturnPathHelper and frontend isValidReturnPath)
  • Avatar download restricted to allowlisted domains to prevent SSRF
  • Content-Length enforcement with chunked transfer encoding bypass protection on avatar downloads
  • PII removed from structured log statements in OAuth flows
  • Mock OAuth provider restricted to @mock.localhost email domains as a production safeguard
  • Migration from JwtSecurityTokenHandler to JsonWebTokenHandler (Microsoft's recommended replacement)
  • __Host-return-path cookie with 5-minute MaxAge and strict validation
  • Sanitized error page id query parameter to alphanumeric and hyphens only

Breaking: __Host- cookie prefix correction:

All authentication cookies have been renamed from __Host_ (underscore) to __Host- (hyphen). The __Host_ prefix was silently ignored by browsers, meaning the cookie security attributes (Secure, Path=/, no Domain) were not being enforced. With the corrected __Host- prefix, browsers now properly enforce these constraints. All existing sessions are invalidated by this change -- every user will need to reauthenticate.

Frontend:

  • "Continue with Google" button on login page and "Sign up with Google" button on signup page with "or" divider
  • Extended error page (/error) with context-specific error codes: user_not_found, account_already_exists, identity_mismatch, authentication_failed, invalid_request, access_denied, session_expired
  • Each error code displays a tailored message, icon, and action buttons (log in, sign up, or contact admin)
  • Error page shows reference ID for support troubleshooting
  • Preferred tenant selection persisted via localStorage and passed during Google login
  • All interactive elements disabled during pending authentication redirects

Infrastructure:

  • Azure Key Vault secrets for Google OAuth Client ID and Client Secret via new key-vault-secrets.bicep module
  • Conditional deployment -- secrets are only created when values are provided
  • GitHub Actions workflow passes OAuth credentials as environment variables during infrastructure deployment
  • New pp set-github-config developer CLI command for configuring GitHub repository variables and secrets

Testing:

  • Comprehensive backend integration tests for all external authentication commands (login, signup, error paths, identity mismatch, expired flows, replay attacks)
  • Domain unit tests for ExternalLogin aggregate lifecycle
  • Unit tests for ExternalAvatarClient, GoogleOAuthProvider at_hash validation, MockOAuthProvider email enforcement, PkceUtilities, and ReturnPathHelper
  • E2E tests using MockOAuthProvider covering the full Google OAuth signup/login cycle, preferred tenant selection, error paths (access denied, token exchange failure, email not verified, user not found), and direct error page rendering with reference IDs

Downstream projects

Breaking: All users must reauthenticate. Authentication cookies have been renamed from __Host_ (underscore) to __Host- (hyphen), which invalidates all existing sessions. No action is needed -- users will simply be prompted to log in again.

Google OAuth is enabled by default. If no Client ID and Client Secret are configured, the buttons will be visible but the OIDC flow cannot complete. To enable Google OAuth, follow the setup instructions in the README section "(Optional) Set up Google OAuth for Sign in with Google".

To remove the Google sign-in buttons (if not using Google OAuth), delete the Google button markup and related state from:

  • application/account-management/WebApp/routes/login/index.tsx -- remove handleGoogleLogin, isGoogleLoginPending, the "or" divider, and the "Continue with Google" button
  • application/account-management/WebApp/routes/signup/index.tsx -- remove handleGoogleSignup, isGoogleSignupPending, the "or" divider, and the "Sign up with Google" button
  • application/account-management/WebApp/shared/images/google-icon.svg -- delete the icon file and its imports

Checklist

  • I have added tests, or done manual regression tests
  • I have updated the documentation, if necessary

@tjementum tjementum self-assigned this Feb 10, 2026
@tjementum tjementum added Enhancement New feature or request Deploy to Staging Set this label on pull requests to deploy code or infrastructure to the Staging environment labels Feb 10, 2026
@tjementum tjementum moved this to 🏗 In Progress in Kanban board Feb 10, 2026
@tjementum tjementum linked an issue Feb 10, 2026 that may be closed by this pull request
@github-actions
Copy link

Approve Database Migration account-management database on stage

The following pending migration(s) will be applied to the database when approved:

  • MergeEmailConfirmationIntoEmailLogin (20260210101500_MergeEmailConfirmationIntoEmailLogin)
  • AddLoginMethodToSessions (20260210102300_AddLoginMethodToSessions)
  • AddExternalLogins (20260210103500_AddExternalLogins)

Migration Script

BEGIN TRANSACTION;
IF NOT EXISTS (
    SELECT * FROM [__EFMigrationsHistory]
    WHERE [MigrationId] = N'20260210101500_MergeEmailConfirmationIntoEmailLogin'
)
BEGIN
    DROP TABLE [Logins];
END;

IF NOT EXISTS (
    SELECT * FROM [__EFMigrationsHistory]
    WHERE [MigrationId] = N'20260210101500_MergeEmailConfirmationIntoEmailLogin'
)
BEGIN
    DECLARE @var nvarchar(max);
    SELECT @var = QUOTENAME([d].[name])
    FROM [sys].[default_constraints] [d]
    INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id]
    WHERE ([d].[parent_object_id] = OBJECT_ID(N'[EmailConfirmations]') AND [c].[name] = N'ValidUntil');
    IF @var IS NOT NULL EXEC(N'ALTER TABLE [EmailConfirmations] DROP CONSTRAINT ' + @var + ';');
    ALTER TABLE [EmailConfirmations] DROP COLUMN [ValidUntil];
END;

IF NOT EXISTS (
    SELECT * FROM [__EFMigrationsHistory]
    WHERE [MigrationId] = N'20260210101500_MergeEmailConfirmationIntoEmailLogin'
)
BEGIN
    DROP INDEX [IX_EmailConfirmations_Email] ON [EmailConfirmations];
END;

IF NOT EXISTS (
    SELECT * FROM [__EFMigrationsHistory]
    WHERE [MigrationId] = N'20260210101500_MergeEmailConfirmationIntoEmailLogin'
)
BEGIN
    EXEC sp_rename N'[EmailConfirmations]', N'EmailLogins', 'OBJECT';
END;

IF NOT EXISTS (
    SELECT * FROM [__EFMigrationsHistory]
    WHERE [MigrationId] = N'20260210101500_MergeEmailConfirmationIntoEmailLogin'
)
BEGIN
    CREATE INDEX [IX_EmailLogins_Email] ON [EmailLogins] ([Email]);
END;

IF NOT EXISTS (
    SELECT * FROM [__EFMigrationsHistory]
    WHERE [MigrationId] = N'20260210101500_MergeEmailConfirmationIntoEmailLogin'
)
BEGIN
    UPDATE EmailLogins SET Id = REPLACE(Id, 'econf_', 'emlog_') WHERE Id LIKE 'econf_%'
END;

IF NOT EXISTS (
    SELECT * FROM [__EFMigrationsHistory]
    WHERE [MigrationId] = N'20260210101500_MergeEmailConfirmationIntoEmailLogin'
)
BEGIN
    INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
    VALUES (N'20260210101500_MergeEmailConfirmationIntoEmailLogin', N'10.0.1');
END;

COMMIT;
GO

BEGIN TRANSACTION;
IF NOT EXISTS (
    SELECT * FROM [__EFMigrationsHistory]
    WHERE [MigrationId] = N'20260210102300_AddLoginMethodToSessions'
)
BEGIN
    ALTER TABLE [Sessions] ADD [LoginMethod] varchar(20) NOT NULL DEFAULT 'OneTimePassword';
END;

IF NOT EXISTS (
    SELECT * FROM [__EFMigrationsHistory]
    WHERE [MigrationId] = N'20260210102300_AddLoginMethodToSessions'
)
BEGIN
    DECLARE @var1 nvarchar(max);
    SELECT @var1 = QUOTENAME([d].[name])
    FROM [sys].[default_constraints] [d]
    INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id]
    WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Sessions]') AND [c].[name] = N'LoginMethod');
    IF @var1 IS NOT NULL EXEC(N'ALTER TABLE [Sessions] DROP CONSTRAINT ' + @var1 + ';');
    ALTER TABLE [Sessions] ALTER COLUMN [LoginMethod] varchar(20) NOT NULL;
END;

IF NOT EXISTS (
    SELECT * FROM [__EFMigrationsHistory]
    WHERE [MigrationId] = N'20260210102300_AddLoginMethodToSessions'
)
BEGIN
    INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
    VALUES (N'20260210102300_AddLoginMethodToSessions', N'10.0.1');
END;

COMMIT;
GO

BEGIN TRANSACTION;
IF NOT EXISTS (
    SELECT * FROM [__EFMigrationsHistory]
    WHERE [MigrationId] = N'20260210103500_AddExternalLogins'
)
BEGIN
    CREATE TABLE [ExternalLogins] (
        [Id] varchar(32) NOT NULL,
        [CreatedAt] datetimeoffset NOT NULL,
        [ModifiedAt] datetimeoffset NULL,
        [ProviderType] varchar(20) NOT NULL,
        [Type] varchar(20) NOT NULL,
        [CodeVerifier] char(128) NOT NULL,
        [Nonce] char(43) NOT NULL,
        [BrowserFingerprint] char(64) NOT NULL,
        [LoginResult] varchar(30) NULL,
        CONSTRAINT [PK_ExternalLogins] PRIMARY KEY ([Id])
    );
END;

IF NOT EXISTS (
    SELECT * FROM [__EFMigrationsHistory]
    WHERE [MigrationId] = N'20260210103500_AddExternalLogins'
)
BEGIN
    ALTER TABLE [Users] ADD [ExternalIdentities] nvarchar(max) NOT NULL DEFAULT N'[]';
END;

IF NOT EXISTS (
    SELECT * FROM [__EFMigrationsHistory]
    WHERE [MigrationId] = N'20260210103500_AddExternalLogins'
)
BEGIN
    INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
    VALUES (N'20260210103500_AddExternalLogins', N'10.0.1');
END;

COMMIT;
GO

@tjementum tjementum removed the Deploy to Staging Set this label on pull requests to deploy code or infrastructure to the Staging environment label Feb 10, 2026
@tjementum tjementum force-pushed the google-authentication branch from 64c966d to 3a72e17 Compare February 10, 2026 20:42
@tjementum tjementum force-pushed the google-authentication branch from 3a72e17 to ac05df0 Compare February 10, 2026 21:50
@tjementum tjementum force-pushed the google-authentication branch from ac05df0 to 122f1b8 Compare February 10, 2026 22:00
@sonarqubecloud
Copy link

@tjementum tjementum marked this pull request as ready for review February 10, 2026 22:08
@tjementum tjementum requested a review from a team as a code owner February 10, 2026 22:08
@tjementum tjementum merged commit 61e38c1 into main Feb 10, 2026
29 checks passed
@tjementum tjementum deleted the google-authentication branch February 10, 2026 22:09
@github-project-automation github-project-automation bot moved this from 🏗 In Progress to ✅ Done in Kanban board Feb 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Enhancement New feature or request

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Google sign up and login

1 participant