feat: enterprise identity provider support — GitHub, Google, Azure AD login

Add external IdP authentication via OAuth (GitHub) and OIDC (Google, Azure AD).
Users can sign in with enterprise credentials; accounts are auto-provisioned
and linked on first login. Configured entirely via environment variables.

- schema/011_external_auth.sql: user_identities table + email column on users
- internal/auth/idp/: provider interface, GitHub OAuth, generic OIDC, store,
  handlers (list providers, login redirect, callback with auto-provisioning)
- internal/auth/user_store.go: GetUserByEmail + SetEmail for IdP linking
- cmd/synapbus/main.go: wire IdP routes + agent provisioner adapter
- web/src/routes/login/+page.svelte: IdP buttons above password form
- Tests: domain restriction, store CRUD, provider listing, user provisioning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Algis Dumbris
2026-03-16 20:37:27 +02:00
co-authored by Claude Opus 4.6
parent bd166843d6
commit 87f24afd58
15 changed files with 1491 additions and 16 deletions
+62
View File
@@ -1,12 +1,41 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { login } from '$lib/stores/auth';
type IdpProvider = {
id: string;
type: string;
display_name: string;
};
let username = $state('');
let password = $state('');
let error = $state('');
let submitting = $state(false);
let providers = $state<IdpProvider[]>([]);
onMount(async () => {
// Check for error from IdP callback
const errParam = $page.url.searchParams.get('error');
if (errParam) {
error = errParam === 'exchange_failed' ? 'Authentication failed. Please try again.'
: errParam === 'provisioning_failed' ? 'Account provisioning failed. Please try again.'
: errParam;
}
// Fetch available identity providers
try {
const res = await fetch('/auth/providers');
if (res.ok) {
const data = await res.json();
providers = data.providers || [];
}
} catch {
// Silently ignore — IdP buttons just won't show
}
});
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
@@ -23,6 +52,15 @@
submitting = false;
}
}
function providerIcon(id: string): string {
switch (id) {
case 'github': return 'M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.341-3.369-1.341-.454-1.155-1.11-1.462-1.11-1.462-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.115 2.504.337 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z';
case 'google': return 'M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z';
case 'azuread': return 'M11.4 2L2 7.33l3.35 2.8-.22.14 4.37 3.63L2 18.6v3.4l9.4-5.5v-.06L22 11.1V7.76l-3.18 1.86-7.42-4.34V2z M12.6 2v3.28l-2.74 1.6 7.32 4.28L22 7.76V7.33L12.6 2z M2 18.6l9.4 5.4.2-.12V19.4l-6.25-3.6L2 18.6z';
default: return '';
}
}
</script>
<div class="min-h-screen flex items-center justify-center bg-bg-primary px-4 relative overflow-hidden">
@@ -72,6 +110,30 @@
</div>
{/if}
<!-- External identity provider buttons -->
{#if providers.length > 0}
<div class="space-y-2.5 mb-5">
{#each providers as provider}
<a
href="/auth/login/{provider.id}"
class="flex items-center justify-center gap-2.5 w-full py-2.5 px-4 rounded border border-border-primary bg-bg-primary hover:bg-bg-primary/80 text-text-primary text-sm font-medium transition-colors"
>
<svg class="w-4.5 h-4.5" viewBox="0 0 24 24" fill="currentColor">
<path d={providerIcon(provider.id)} />
</svg>
Sign in with {provider.display_name}
</a>
{/each}
</div>
<!-- Divider -->
<div class="flex items-center gap-3 mb-5">
<div class="flex-1 h-px bg-border-primary"></div>
<span class="text-xs text-text-secondary">or</span>
<div class="flex-1 h-px bg-border-primary"></div>
</div>
{/if}
<form class="space-y-4" onsubmit={handleSubmit}>
<div>
<label for="username" class="block text-xs font-medium text-text-secondary mb-1.5">Username</label>