Menu

Change language

GitHub repo

Sso Identity Provider Config

blocksecurity

Enterprise Single Sign-On (SAML 2.0 / OIDC) identity provider configuration wizard with IdP preset picker, SP credentials, XML metadata dropzone, X.509 cert PEM validator, domain enforcement switch, and interactive SAML test runner.

Installation

$npx uipkge-ng@latest add sso-identity-provider-config
Manifest:https://uipkge.dev/r/angular/sso-identity-provider-config.jsonInstalls to:src/app/components/blocks/sso-identity-provider-config/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

IdpPreset
interface IdpPreset {
  id: IdpPresetId
  name: string
  subtitle: string
  badge: string
  defaultSsoUrl: string
  defaultEntityId: string
  domain: string
  defaultCert: string
}
ClaimAttribute
interface ClaimAttribute {
  samlAttribute: string
  mappedField: string
  sampleValue: string
}

Files installed (2)

  • src/app/components/blocks/sso-identity-provider-config/sso-identity-provider-config.component.ts37.6 kB
    // Enterprise Single Sign-On (SAML 2.0 / OIDC) identity provider configuration wizard
    // with IdP preset picker, SP credentials, XML metadata dropzone, X.509 cert PEM
    // validator, domain enforcement switch, and interactive SAML test runner.
    // Port of the Vue block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      Check,
      CheckCircle2,
      Copy,
      Download,
      ExternalLink,
      FileCode,
      Globe,
      KeyRound,
      Lock,
      LucideAngularModule,
      Play,
      RotateCw,
      Save,
      ShieldAlert,
      ShieldCheck,
      UploadCloud,
      UserCheck,
      X,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardFooterComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import {
      UiDialogComponent,
      UiDialogContentComponent,
      UiDialogDescriptionComponent,
      UiDialogFooterComponent,
      UiDialogHeaderComponent,
      UiDialogTitleComponent,
    } from '@/ui/dialog/dialog.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import { UiSwitchComponent } from '@/ui/switch/switch.component'
    import { UiTextareaComponent } from '@/ui/textarea/textarea.component'
    
    export type IdpPresetId = 'okta' | 'entra' | 'google' | 'custom'
    
    interface IdpPreset {
      id: IdpPresetId
      name: string
      subtitle: string
      badge: string
      defaultSsoUrl: string
      defaultEntityId: string
      domain: string
      defaultCert: string
    }
    
    interface ClaimAttribute {
      samlAttribute: string
      mappedField: string
      sampleValue: string
    }
    
    const idpPresets: IdpPreset[] = [
      {
        id: 'okta',
        name: 'Okta',
        subtitle: 'SAML 2.0 & OIDC Enterprise',
        badge: 'Popular',
        defaultSsoUrl: 'https://company.okta.com/app/sso/saml',
        defaultEntityId: 'http://www.okta.com/exk849201',
        domain: 'company.okta.com',
        defaultCert: `-----BEGIN CERTIFICATE-----
    MIIDpDCCAoygAwIBAgIGAX2v5L3oMA0GCSqGSIb3DQEBCwUAMIGSMQswCQYDVQQGEwJV
    UzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsG
    A1UECgwET2t0YTEUMBIGA1UECwwLU1NPIFNlcnZpY2UxGDAWBgNVBAMMD2NvbXBhbnkub2t0
    YS5jb20xIDAeBgkqhkiG9w0BCQEWEWFkbWluQG9rdGEuY29tMB4XDTI2MDgyMTE0MzAwMFoX
    DTI4MTAyMTE0MzAwMFowgZIxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlh
    -----END CERTIFICATE-----`,
      },
      {
        id: 'entra',
        name: 'Microsoft Entra ID',
        subtitle: 'Azure AD & Microsoft 365',
        badge: 'Enterprise',
        defaultSsoUrl: 'https://login.microsoftonline.com/72f988bf-86f1-41af-91ab-2d7cd011db47/saml2',
        defaultEntityId: 'https://sts.windows.net/72f988bf-86f1-41af-91ab-2d7cd011db47/',
        domain: 'login.microsoftonline.com',
        defaultCert: `-----BEGIN CERTIFICATE-----
    MIIDBTCCAe2gAwIBAgIQJn0zJk1vVbtJpZ0R4k0D1DANBgkqhkiG9w0BAQsFADAtMSsw
    KQYDVQQDEyJhY2NvdW50cy5hY2Nlc3Njb250cm9sLndpbmRvd3MubmV0MB4XDTI2MDgy
    MTE0MzAwMFoXDTI4MTAyMTE0MzAwMFowLTErMCkGA1UEAxMiYWNjb3VudHMuYWNjZXNz
    Y29udHJvbC53aW5kb3dzLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
    -----END CERTIFICATE-----`,
      },
      {
        id: 'google',
        name: 'Google Workspace',
        subtitle: 'Google Cloud Identity SAML',
        badge: 'SAML 2.0',
        defaultSsoUrl: 'https://accounts.google.com/o/saml2/idp?idpid=C0123abcd',
        defaultEntityId: 'https://accounts.google.com/o/saml2?idpid=C0123abcd',
        domain: 'accounts.google.com',
        defaultCert: `-----BEGIN CERTIFICATE-----
    MIIDdDCCAlygAwIBAgIGAY7GZpBqMA0GCSqGSIb3DQEBCwUAMHQxCzAJBgNVBAYTAlVT
    MRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MR8wHQYD
    VQQKExZHb29nbGUgV29ya3NwYWNlIElBTSExMB8GA1UEAxMYR29vZ2xlIElkUCBTYW1s
    IFNpZ25lcjAeFw0yNjA4MjExNDMwMDBaFw0yODEwMjExNDMwMDBaMHQxCzAJBgNVBAYT
    -----END CERTIFICATE-----`,
      },
      {
        id: 'custom',
        name: 'Custom SAML 2.0',
        subtitle: 'PingFederate, OneLogin, Keycloak',
        badge: 'Universal',
        defaultSsoUrl: 'https://sso.company.com/idp/profile/SAML2/POST/SSO',
        defaultEntityId: 'https://sso.company.com/idp/shibboleth',
        domain: 'sso.company.com',
        defaultCert: `-----BEGIN CERTIFICATE-----
    MIIDcDCCAligAwIBAgIJAN8FpW1mG+hBMA0GCSqGSIb3DQEBCwUAMFgxCzAJBgNVBAYT
    AlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMQ8wDQYDVQQKDAZDdXN0b20xIDAeBgNVBAMM
    F2N1c3RvbS5pZHAucHJvdmlkZXIubGFiMB4XDTI2MDgyMTE0MzAwMFoXDTI4MTAyMTE0
    MzAwMFowWDELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExDzANBgNVBAoM
    -----END CERTIFICATE-----`,
      },
    ]
    
    const spAcsUrl = 'https://auth.uipkge.dev/sso/saml/callback'
    const spEntityId = 'https://auth.uipkge.dev/sso/saml/metadata'
    const spMetadataXmlUrl = 'https://auth.uipkge.dev/sso/saml/metadata.xml'
    const spCertificatePem = `-----BEGIN CERTIFICATE-----
    MIIDODCCAiCgAwIBAgIUW0Qz0K7VzN8XlP4wQ6k9J1vYgLAwDQYJKoZIhvcNAQEL
    BQAwMzEXMBUGA1UEAwwNYXV0aC51aXBrZ2UuZGV2MRgwFgYDVQQKDA9VSVBLR0Ug
    QXV0aCBTU08wHhcNMjYwODIxMTQzMDAwWhcNMzEwODIxMTQzMDAwWjAzMRcwFQYD
    VQQDDA5hdXRoLnVpcGtnZS5kZXYxGDAWBgNVBAoMD1VJUEtHRSBBdXRoIFNTTzCC
    ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL1K9sU3FvX+7nQ6m2W4j1k9
    -----END CERTIFICATE-----`
    
    const simulatedClaims: ClaimAttribute[] = [
      { samlAttribute: 'urn:oid:0.9.2342.19200300.100.1.1', mappedField: 'nameID', sampleValue: '[email protected]' },
      {
        samlAttribute: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
        mappedField: 'email',
        sampleValue: '[email protected]',
      },
      {
        samlAttribute: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname',
        mappedField: 'firstName',
        sampleValue: 'Alex',
      },
      {
        samlAttribute: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname',
        mappedField: 'lastName',
        sampleValue: 'Morgan',
      },
      {
        samlAttribute: 'http://schemas.xmlsoap.org/claims/Group',
        mappedField: 'groups',
        sampleValue: '["Engineering", "IAM-Admins", "SSO-Users"]',
      },
      {
        samlAttribute: 'urn:oasis:names:tc:SAML:2.0:assertion:SessionIndex',
        mappedField: 'sessionIndex',
        sampleValue: '_sess_8f29ab0192e47',
      },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-sso-identity-provider-config, [ui-sso-identity-provider-config]',
      standalone: true,
      // Vue renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardFooterComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiDialogComponent,
        UiDialogContentComponent,
        UiDialogDescriptionComponent,
        UiDialogFooterComponent,
        UiDialogHeaderComponent,
        UiDialogTitleComponent,
        UiInputComponent,
        UiSeparatorComponent,
        UiSwitchComponent,
        UiTextareaComponent,
      ],
      template: `
        <div data-slot="sso-identity-provider-config" [class]="rootClass">
          <!-- Header Banner & Top Controls -->
          <div ui-card class="border-border bg-card/80 shadow-xs backdrop-blur-xs">
            <div ui-card-header class="pb-4">
              <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
                <div class="space-y-1">
                  <div class="flex flex-wrap items-center gap-2.5">
                    <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-lg">
                      <lucide-icon [img]="KeyRound" class="size-4" />
                    </div>
                    <h3 ui-card-title class="text-base font-semibold">Single Sign-On (SSO) & SAML 2.0 Configuration</h3>
                    @if (ssoEnabled()) {
                      <span ui-badge variant="success" class="font-mono text-xs">
                        <span class="bg-success mr-1.5 inline-block size-2 animate-pulse rounded-full"></span>
                        SSO Active
                      </span>
                    } @else {
                      <span ui-badge variant="outline" class="text-muted-foreground border-border font-mono text-xs">
                        Disabled
                      </span>
                    }
                  </div>
                  <p ui-card-description class="text-xs">
                    Connect your enterprise IdP (Okta, Azure AD / Entra, Google Workspace, OneLogin) for federated SAML
                    authentication.
                  </p>
                </div>
    
                <div class="flex flex-wrap items-center gap-3">
                  <div class="border-border bg-muted/30 flex items-center gap-3 rounded-lg border px-3 py-1.5">
                    <div class="text-right">
                      <p class="text-foreground text-xs font-medium">SSO Status</p>
                      <p class="text-muted-foreground text-xs">
                        {{ ssoEnabled() ? 'Enabled for @company.com' : 'Disabled' }}
                      </p>
                    </div>
                    <button
                      ui-switch
                      [checked]="ssoEnabled()"
                      (checkedChange)="ssoEnabled.set($event)"
                      aria-label="Toggle Single Sign-On"
                    ></button>
                  </div>
    
                  <button
                    ui-button
                    variant="outline"
                    size="sm"
                    class="gap-1.5 text-xs font-medium"
                    [disabled]="!ssoEnabled()"
                    (click)="openTestModal()"
                  >
                    <lucide-icon [img]="Play" class="size-3.5" />
                    <span>Test SAML Login</span>
                  </button>
                </div>
              </div>
            </div>
          </div>
    
          <!-- Save Success Banner -->
          @if (saveSuccessNotice()) {
            <div
              class="border-success/30 bg-success/10 text-success flex items-center justify-between rounded-lg border px-4 py-3 text-xs"
              role="status"
            >
              <div class="flex items-center gap-2">
                <lucide-icon [img]="CheckCircle2" class="size-4 shrink-0" />
                <span>
                  SSO configuration saved successfully. All assertions will validate against {{ currentPreset().name }}.
                </span>
              </div>
              <button
                type="button"
                class="text-success hover:opacity-75"
                aria-label="Dismiss alert"
                (click)="saveSuccessNotice.set(false)"
              >
                <lucide-icon [img]="X" class="size-3.5" />
              </button>
            </div>
          }
    
          <!-- IdP Preset Picker Grid -->
          <div class="space-y-3">
            <div class="flex items-center justify-between">
              <div class="space-y-0.5">
                <h3 class="text-foreground text-xs font-semibold tracking-tight uppercase">Identity Provider Presets</h3>
                <p class="text-muted-foreground text-xs">
                  Choose your identity platform to load SAML endpoints and formatting defaults
                </p>
              </div>
              <span ui-badge variant="outline" class="border-border font-mono text-xs">SAML 2.0 / Web SSO</span>
            </div>
    
            <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
              @for (preset of presets; track preset.id) {
                <button type="button" [class]="presetCardClass(preset.id)" (click)="handleSelectPreset(preset.id)">
                  <div class="space-y-2">
                    <div class="flex items-center justify-between">
                      <div [class]="presetIconClass(preset.id)">
                        @if (preset.id === 'okta') {
                          <span>O</span>
                        } @else if (preset.id === 'entra') {
                          <span>⊞</span>
                        } @else if (preset.id === 'google') {
                          <span>G</span>
                        } @else {
                          <lucide-icon [img]="Globe" class="size-4" />
                        }
                      </div>
                      <span
                        ui-badge
                        [variant]="selectedPresetId() === preset.id ? 'default' : 'secondary'"
                        class="font-mono text-xs"
                      >
                        {{ preset.badge }}
                      </span>
                    </div>
    
                    <div>
                      <p class="text-foreground text-sm font-semibold">{{ preset.name }}</p>
                      <p class="text-muted-foreground mt-0.5 text-xs">{{ preset.subtitle }}</p>
                    </div>
                  </div>
    
                  <div class="border-border/60 mt-4 flex items-center justify-between border-t pt-2 text-xs">
                    <span class="text-muted-foreground max-w-[140px] truncate font-mono text-xs">{{ preset.domain }}</span>
                    @if (selectedPresetId() === preset.id) {
                      <span class="text-primary flex items-center gap-1 text-xs font-medium">
                        <lucide-icon [img]="Check" class="size-3" />
                        <span>Active</span>
                      </span>
                    } @else {
                      <span class="text-muted-foreground group-hover:text-foreground text-xs">Select →</span>
                    }
                  </div>
                </button>
              }
            </div>
          </div>
    
          <!-- 2-Column Configuration Form -->
          <div class="grid grid-cols-1 gap-6 lg:grid-cols-12">
            <!-- Left Column: Service Provider (SP) Credentials -->
            <div class="space-y-6 lg:col-span-6">
              <div ui-card class="border-border bg-card shadow-xs">
                <div ui-card-header class="pb-4">
                  <div class="flex items-center justify-between">
                    <div class="flex items-center gap-2">
                      <div class="bg-primary/10 text-primary flex size-7 items-center justify-center rounded-md">
                        <lucide-icon [img]="ShieldCheck" class="size-4" />
                      </div>
                      <div>
                        <h3 ui-card-title class="text-sm font-semibold">Service Provider (SP) Credentials</h3>
                        <p ui-card-description class="text-xs">
                          Copy these values into your Identity Provider's SAML setup
                        </p>
                      </div>
                    </div>
                    <span ui-badge variant="outline" class="font-mono text-xs">Our Endpoints</span>
                  </div>
                </div>
    
                <div ui-card-content class="space-y-4 pt-0">
                  <div class="space-y-1.5">
                    <div class="flex items-center justify-between">
                      <label for="angular-sp-acs-url" class="text-foreground text-xs font-medium">
                        Assertion Consumer Service (ACS) URL
                      </label>
                      <button
                        ui-button
                        variant="ghost"
                        size="sm"
                        class="text-muted-foreground hover:text-foreground h-6 px-2 text-xs"
                        (click)="copyText(spAcsUrlValue, 'acs')"
                      >
                        @if (copiedKey() === 'acs') {
                          <lucide-icon [img]="Check" class="text-success mr-1 size-3" />
                        } @else {
                          <lucide-icon [img]="Copy" class="mr-1 size-3" />
                        }
                        <span>{{ copiedKey() === 'acs' ? 'Copied' : 'Copy' }}</span>
                      </button>
                    </div>
                    <ui-input
                      id="angular-sp-acs-url"
                      [value]="spAcsUrlValue"
                      [readonly]="true"
                      class="bg-muted/30 font-mono text-xs select-all"
                    />
                    <p class="text-muted-foreground text-xs">
                      The callback URL where your IdP transmits HTTP-POST SAML assertion responses.
                    </p>
                  </div>
    
                  <div class="space-y-1.5">
                    <div class="flex items-center justify-between">
                      <label for="angular-sp-entity-id" class="text-foreground text-xs font-medium">
                        SP Entity ID / Audience URI
                      </label>
                      <button
                        ui-button
                        variant="ghost"
                        size="sm"
                        class="text-muted-foreground hover:text-foreground h-6 px-2 text-xs"
                        (click)="copyText(spEntityIdValue, 'entity-id')"
                      >
                        @if (copiedKey() === 'entity-id') {
                          <lucide-icon [img]="Check" class="text-success mr-1 size-3" />
                        } @else {
                          <lucide-icon [img]="Copy" class="mr-1 size-3" />
                        }
                        <span>{{ copiedKey() === 'entity-id' ? 'Copied' : 'Copy' }}</span>
                      </button>
                    </div>
                    <ui-input
                      id="angular-sp-entity-id"
                      [value]="spEntityIdValue"
                      [readonly]="true"
                      class="bg-muted/30 font-mono text-xs select-all"
                    />
                    <p class="text-muted-foreground text-xs">
                      The unique audience restriction URI identifying our service to your IdP.
                    </p>
                  </div>
    
                  <div class="border-border bg-muted/20 space-y-2 rounded-lg border p-3">
                    <p class="text-foreground text-xs font-medium">Protocol & Binding Requirements</p>
                    <div class="grid grid-cols-2 gap-2 text-xs">
                      <div>
                        <span class="text-muted-foreground block text-xs">NameID Format</span>
                        <span class="text-foreground font-mono text-xs font-medium">EmailAddress (urn:oasis:...)</span>
                      </div>
                      <div>
                        <span class="text-muted-foreground block text-xs">SAML Binding</span>
                        <span class="text-foreground font-mono text-xs font-medium">HTTP-POST</span>
                      </div>
                      <div>
                        <span class="text-muted-foreground block text-xs">Signing Algorithm</span>
                        <span class="text-foreground font-mono text-xs font-medium">RSA-SHA256</span>
                      </div>
                      <div>
                        <span class="text-muted-foreground block text-xs">Assertion Encryption</span>
                        <span class="text-foreground font-mono text-xs font-medium">Optional (AES-256-GCM)</span>
                      </div>
                    </div>
                  </div>
    
                  <ui-separator />
    
                  <div class="space-y-2">
                    <div class="flex items-center justify-between">
                      <label class="text-foreground text-xs font-medium">SP X.509 Public Certificate</label>
                      <div class="flex items-center gap-1.5">
                        <button
                          ui-button
                          variant="ghost"
                          size="sm"
                          class="text-muted-foreground hover:text-foreground h-6 px-2 text-xs"
                          (click)="copyText(spCertificatePemValue, 'sp-cert')"
                        >
                          @if (copiedKey() === 'sp-cert') {
                            <lucide-icon [img]="Check" class="text-success mr-1 size-3" />
                          } @else {
                            <lucide-icon [img]="Copy" class="mr-1 size-3" />
                          }
                          <span>{{ copiedKey() === 'sp-cert' ? 'Copied' : 'Copy PEM' }}</span>
                        </button>
                        <button
                          ui-button
                          aria-label="Download attachment"
                          variant="outline"
                          size="sm"
                          class="h-6 gap-1 px-2 text-xs"
                          (click)="downloadSpCertificate()"
                        >
                          <lucide-icon [img]="Download" class="size-3" />
                          <span>Download .crt</span>
                        </button>
                      </div>
                    </div>
    
                    <pre
                      class="border-border bg-muted/40 text-foreground max-h-[140px] overflow-x-auto rounded-md border p-3 font-mono text-xs leading-relaxed select-all"
                    ><code>{{ spCertificatePemValue }}</code></pre>
                    <p class="text-muted-foreground text-xs">
                      Import this certificate into your IdP if assertion signing or encryption is required.
                    </p>
                  </div>
                </div>
    
                <div
                  ui-card-footer
                  class="border-border text-muted-foreground flex items-center justify-between border-t pt-3 text-xs"
                >
                  <span class="flex items-center gap-1.5">
                    <lucide-icon [img]="FileCode" class="size-3.5" />
                    <span>SP Metadata XML Endpoint</span>
                  </span>
                  <button
                    ui-button
                    variant="ghost"
                    size="sm"
                    class="text-primary hover:text-primary/80 h-6 gap-1 px-2 text-xs"
                    (click)="copyText(spMetadataXmlUrlValue, 'meta-url')"
                  >
                    <span>{{ copiedKey() === 'meta-url' ? 'Copied URL' : 'Copy metadata.xml URL' }}</span>
                    <lucide-icon [img]="ExternalLink" class="size-3" />
                  </button>
                </div>
              </div>
            </div>
    
            <!-- Right Column: Identity Provider (IdP) Metadata Form -->
            <div class="space-y-6 lg:col-span-6">
              <div ui-card class="border-border bg-card shadow-xs">
                <div ui-card-header class="pb-4">
                  <div class="flex items-center justify-between">
                    <div class="flex items-center gap-2">
                      <div class="bg-primary/10 text-primary flex size-7 items-center justify-center rounded-md">
                        <lucide-icon [img]="Lock" class="size-4" />
                      </div>
                      <div>
                        <h3 ui-card-title class="text-sm font-semibold">Identity Provider (IdP) Metadata</h3>
                        <p ui-card-description class="text-xs">
                          Configure SAML endpoints and credentials provided by {{ currentPreset().name }}
                        </p>
                      </div>
                    </div>
                    <span ui-badge variant="secondary" class="font-mono text-xs">{{ currentPreset().name }}</span>
                  </div>
                </div>
    
                <div ui-card-content class="space-y-4 pt-0">
                  <div
                    [class]="dropzoneClass"
                    (dragover)="handleXmlDragOver($event)"
                    (dragleave)="handleXmlDragLeave()"
                    (drop)="handleXmlDrop($event)"
                    (click)="simulateXmlUpload()"
                  >
                    <div class="bg-primary/10 text-primary mb-2 grid size-9 place-items-center rounded-full">
                      <lucide-icon [img]="UploadCloud" class="size-4" />
                    </div>
                    <p class="text-foreground text-xs font-semibold">
                      Drag & drop IdP <span class="font-mono">metadata.xml</span> to auto-fill
                    </p>
                    <p class="text-muted-foreground mt-0.5 text-xs">
                      Or click here to simulate parsing your Identity Provider metadata file
                    </p>
                  </div>
    
                  @if (xmlUploadedNotice()) {
                    <div
                      class="border-success/30 bg-success/10 text-success flex items-center gap-2 rounded-lg border p-2.5 text-xs"
                    >
                      <lucide-icon [img]="CheckCircle2" class="size-4 shrink-0" />
                      <span>Parsed SSO URL, Entity ID, and X.509 Certificate from metadata XML.</span>
                    </div>
                  }
    
                  <div class="space-y-1.5">
                    <label for="angular-idp-sso-url" class="text-foreground text-xs font-medium">
                      IdP Single Sign-On (SSO) URL
                    </label>
                    <ui-input
                      id="angular-idp-sso-url"
                      [value]="idpSsoUrl()"
                      (valueChange)="idpSsoUrl.set($event)"
                      placeholder="https://company.okta.com/app/sso/saml"
                      class="font-mono text-xs"
                    />
                    <p class="text-muted-foreground text-xs">
                      The HTTP-POST endpoint at your IdP where users are redirected for authentication.
                    </p>
                  </div>
    
                  <div class="space-y-1.5">
                    <label for="angular-idp-entity-id" class="text-foreground text-xs font-medium">
                      IdP Entity ID / Issuer URI
                    </label>
                    <ui-input
                      id="angular-idp-entity-id"
                      [value]="idpEntityId()"
                      (valueChange)="idpEntityId.set($event)"
                      placeholder="http://www.okta.com/exk849201"
                      class="font-mono text-xs"
                    />
                    <p class="text-muted-foreground text-xs">
                      The unique issuer string provided in your IdP metadata document.
                    </p>
                  </div>
    
                  <div class="space-y-1.5">
                    <div class="flex items-center justify-between">
                      <label for="angular-idp-cert-pem" class="text-foreground text-xs font-medium">
                        IdP X.509 Signing Certificate (PEM)
                      </label>
                      <span ui-badge variant="outline" class="border-success/40 text-success font-mono text-xs">
                        Valid (Expires Oct 2028)
                      </span>
                    </div>
                    <ui-textarea
                      id="angular-idp-cert-pem"
                      [value]="idpCertPem()"
                      (valueChange)="idpCertPem.set($event)"
                      [rows]="5"
                      class="border-border/70 font-mono text-xs leading-relaxed"
                      placeholder="-----BEGIN CERTIFICATE-----..."
                    />
                    <p class="text-muted-foreground text-xs">
                      Public X.509 certificate used by our SP to verify the cryptographic signature on SAML assertions.
                    </p>
                  </div>
    
                  <ui-separator />
    
                  <div class="border-border bg-muted/20 flex items-start justify-between gap-3 rounded-lg border p-3">
                    <div class="space-y-0.5">
                      <div class="flex items-center gap-1.5">
                        <lucide-icon [img]="ShieldAlert" class="text-primary size-4" />
                        <p class="text-foreground text-xs font-semibold">Enforce SSO for all domain emails</p>
                      </div>
                      <p class="text-muted-foreground text-xs">
                        Require all users with <span class="text-foreground font-mono font-medium">@company.com</span> email
                        addresses to log in via SSO. Password logins will be blocked.
                      </p>
                    </div>
                    <button
                      ui-switch
                      class="mt-1"
                      [checked]="enforceDomain()"
                      (checkedChange)="enforceDomain.set($event)"
                      aria-label="Enforce SSO for domain"
                    ></button>
                  </div>
                </div>
    
                <div ui-card-footer class="border-border flex flex-wrap items-center justify-between gap-2 border-t pt-4">
                  <button
                    ui-button
                    variant="ghost"
                    size="sm"
                    class="text-muted-foreground hover:text-foreground text-xs"
                    (click)="handleSelectPreset(selectedPresetId())"
                  >
                    Reset to Defaults
                  </button>
                  <button
                    ui-button
                    size="sm"
                    class="gap-1.5 text-xs font-medium shadow-xs"
                    [disabled]="isSaving() || !idpSsoUrl().trim() || !idpEntityId().trim()"
                    (click)="handleSaveConfig()"
                  >
                    @if (isSaving()) {
                      <lucide-icon [img]="RotateCw" class="size-3.5 animate-spin" />
                    } @else {
                      <lucide-icon [img]="Save" class="size-3.5" />
                    }
                    <span>{{ isSaving() ? 'Saving...' : 'Save SSO Configuration' }}</span>
                  </button>
                </div>
              </div>
            </div>
          </div>
    
          <!-- Test SAML Login Diagnostic Dialog -->
          <ui-dialog [open]="testModalOpen()" (openChange)="testModalOpen.set($event)">
            <ui-dialog-content class="sm:max-w-xl">
              <div ui-dialog-header>
                <div class="flex items-center gap-2">
                  <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-lg">
                    <lucide-icon [img]="UserCheck" class="size-4" />
                  </div>
                  <div>
                    <h2 ui-dialog-title>SAML 2.0 Authentication Diagnostic</h2>
                    <p ui-dialog-description>Live assertion handshake simulation with {{ currentPreset().name }}</p>
                  </div>
                </div>
              </div>
    
              <div class="space-y-4 py-2">
                <div
                  class="border-success/30 bg-success/10 text-success flex items-center justify-between rounded-lg border p-3 text-xs"
                >
                  <div class="flex items-center gap-2">
                    <lucide-icon [img]="CheckCircle2" class="size-4 shrink-0" />
                    <div>
                      <p class="font-semibold">HTTP 200 OK — SAML Handshake Validated</p>
                      <p class="text-xs opacity-90">All signature envelopes matched X.509 certificate</p>
                    </div>
                  </div>
                  <span ui-badge variant="success" class="font-mono text-xs">Success</span>
                </div>
    
                <div class="border-border bg-muted/20 space-y-2 rounded-lg border p-3 text-xs">
                  <h4 class="text-foreground font-semibold">Verification Pipeline</h4>
                  <div class="space-y-1.5">
                    <div class="text-foreground flex items-center gap-2">
                      <lucide-icon [img]="Check" class="text-success size-3.5 shrink-0" />
                      <span>
                        1. AuthnRequest dispatched with SP signature to
                        <code class="font-mono text-xs">{{ idpSsoUrl() }}</code>
                      </span>
                    </div>
                    <div class="text-foreground flex items-center gap-2">
                      <lucide-icon [img]="Check" class="text-success size-3.5 shrink-0" />
                      <span>2. SAMLResponse assertion received and verified with RSA-SHA256 signature</span>
                    </div>
                    <div class="text-foreground flex items-center gap-2">
                      <lucide-icon [img]="Check" class="text-success size-3.5 shrink-0" />
                      <span>
                        3. AudienceRestriction matched <code class="font-mono text-xs">{{ spEntityIdValue }}</code>
                      </span>
                    </div>
                    <div class="text-foreground flex items-center gap-2">
                      <lucide-icon [img]="Check" class="text-success size-3.5 shrink-0" />
                      <span>4. Assertion timestamp valid within 300s clock-skew tolerance window</span>
                    </div>
                  </div>
                </div>
    
                <div class="space-y-1.5">
                  <h4 class="text-foreground text-xs font-semibold">Asserted Identity Claims & Attributes</h4>
                  <div class="border-border bg-muted/10 overflow-hidden rounded-md border text-xs">
                    <table class="w-full">
                      <thead>
                        <tr class="border-border bg-muted/40 text-muted-foreground border-b text-left font-medium">
                          <th class="p-2 text-xs">Field</th>
                          <th class="p-2 text-xs">Sample Claim Value</th>
                        </tr>
                      </thead>
                      <tbody class="divide-border divide-y font-mono text-xs">
                        @for (claim of claims; track claim.mappedField) {
                          <tr class="hover:bg-muted/20">
                            <td class="text-primary p-2 font-semibold">{{ claim.mappedField }}</td>
                            <td class="text-foreground max-w-[240px] truncate p-2">{{ claim.sampleValue }}</td>
                          </tr>
                        }
                      </tbody>
                    </table>
                  </div>
                </div>
              </div>
    
              <div ui-dialog-footer>
                <button ui-button variant="outline" size="sm" (click)="testModalOpen.set(false)">Close</button>
                <button ui-button size="sm" class="gap-1.5" (click)="openTestModal()">
                  <lucide-icon [img]="RotateCw" class="size-3.5" />
                  <span>Re-test Handshake</span>
                </button>
              </div>
            </ui-dialog-content>
          </ui-dialog>
        </div>
      `,
    })
    export class UiSsoIdentityProviderConfigComponent {
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Copy = Copy
      protected readonly Download = Download
      protected readonly ExternalLink = ExternalLink
      protected readonly FileCode = FileCode
      protected readonly Globe = Globe
      protected readonly KeyRound = KeyRound
      protected readonly Lock = Lock
      protected readonly Play = Play
      protected readonly RotateCw = RotateCw
      protected readonly Save = Save
      protected readonly ShieldAlert = ShieldAlert
      protected readonly ShieldCheck = ShieldCheck
      protected readonly UploadCloud = UploadCloud
      protected readonly UserCheck = UserCheck
      protected readonly X = X
      protected readonly presets = idpPresets
      protected readonly claims = simulatedClaims
      protected readonly spAcsUrlValue = spAcsUrl
      protected readonly spEntityIdValue = spEntityId
      protected readonly spMetadataXmlUrlValue = spMetadataXmlUrl
      protected readonly spCertificatePemValue = spCertificatePem
    
      @Input() set initialSsoEnabled(v: boolean) {
        this.ssoEnabled.set(v ?? true)
      }
      @Input() set initialPreset(v: IdpPresetId) {
        if (v) this.handleSelectPreset(v)
      }
      @Input() set initialEnforceDomain(v: boolean) {
        this.enforceDomain.set(v ?? true)
      }
      @Input('class') className?: string
    
      readonly ssoEnabled = signal(true)
      readonly selectedPresetId = signal<IdpPresetId>('okta')
      readonly enforceDomain = signal(true)
    
      readonly currentPreset = computed(() => idpPresets.find((p) => p.id === this.selectedPresetId()) ?? idpPresets[0]!)
    
      readonly idpSsoUrl = signal(idpPresets[0]!.defaultSsoUrl)
      readonly idpEntityId = signal(idpPresets[0]!.defaultEntityId)
      readonly idpCertPem = signal(idpPresets[0]!.defaultCert)
    
      readonly copiedKey = signal<string | null>(null)
      private copyTimer: ReturnType<typeof setTimeout> | undefined
    
      readonly isDraggingXml = signal(false)
      readonly xmlUploadedNotice = signal(false)
    
      readonly isSaving = signal(false)
      readonly saveSuccessNotice = signal(false)
    
      readonly testModalOpen = signal(false)
      readonly isTesting = signal(false)
    
      get rootClass(): string {
        return cn('w-full space-y-6', this.className)
      }
    
      get dropzoneClass(): string {
        return cn(
          'border-border relative flex cursor-pointer flex-col items-center justify-center rounded-xl border border-dashed p-4 text-center transition-colors',
          this.isDraggingXml() ? 'border-primary bg-primary/10' : 'bg-muted/20 hover:border-primary/50 hover:bg-muted/40',
        )
      }
    
      presetCardClass(id: IdpPresetId): string {
        return cn(
          'group relative flex flex-col justify-between rounded-xl border p-4 text-left transition-colors duration-150',
          'focus-visible:ring-ring cursor-pointer focus-visible:ring-2 focus-visible:outline-none',
          this.selectedPresetId() === id
            ? 'border-primary bg-primary/5 ring-primary shadow-xs ring-1'
            : 'border-border bg-card hover:border-primary/40 hover:bg-accent/40',
        )
      }
    
      presetIconClass(id: IdpPresetId): string {
        return cn(
          'flex size-8 items-center justify-center rounded-lg text-xs font-bold transition-colors',
          this.selectedPresetId() === id
            ? 'bg-primary text-primary-foreground'
            : 'bg-muted text-foreground group-hover:bg-primary/10 group-hover:text-primary',
        )
      }
    
      async copyText(text: string, key: string): Promise<void> {
        try {
          await navigator.clipboard.writeText(text)
          this.copiedKey.set(key)
          clearTimeout(this.copyTimer)
          this.copyTimer = setTimeout(() => {
            if (this.copiedKey() === key) this.copiedKey.set(null)
          }, 2000)
        } catch {
          // Clipboard unavailable
        }
      }
    
      handleSelectPreset(presetId: IdpPresetId): void {
        this.selectedPresetId.set(presetId)
        const preset = idpPresets.find((p) => p.id === presetId)
        if (preset) {
          this.idpSsoUrl.set(preset.defaultSsoUrl)
          this.idpEntityId.set(preset.defaultEntityId)
          this.idpCertPem.set(preset.defaultCert)
          this.xmlUploadedNotice.set(false)
        }
      }
    
      downloadSpCertificate(): void {
        if (typeof window === 'undefined') return
        const blob = new Blob([spCertificatePem], { type: 'application/x-pem-file' })
        const url = URL.createObjectURL(blob)
        const anchor = document.createElement('a')
        anchor.href = url
        anchor.download = 'uipkge-sp-certificate.crt'
        document.body.appendChild(anchor)
        anchor.click()
        document.body.removeChild(anchor)
        URL.revokeObjectURL(url)
      }
    
      handleXmlDrop(e: DragEvent): void {
        e.preventDefault()
        this.isDraggingXml.set(false)
        this.simulateXmlUpload()
      }
    
      handleXmlDragOver(e: DragEvent): void {
        e.preventDefault()
        this.isDraggingXml.set(true)
      }
    
      handleXmlDragLeave(): void {
        this.isDraggingXml.set(false)
      }
    
      simulateXmlUpload(): void {
        const preset = this.currentPreset()
        this.idpSsoUrl.set(preset.defaultSsoUrl)
        this.idpEntityId.set(preset.defaultEntityId)
        this.idpCertPem.set(preset.defaultCert)
        this.xmlUploadedNotice.set(true)
        setTimeout(() => this.xmlUploadedNotice.set(false), 4000)
      }
    
      handleSaveConfig(): void {
        if (this.isSaving()) return
        this.isSaving.set(true)
        setTimeout(() => {
          this.isSaving.set(false)
          this.saveSuccessNotice.set(true)
          setTimeout(() => this.saveSuccessNotice.set(false), 3500)
        }, 600)
      }
    
      openTestModal(): void {
        this.testModalOpen.set(true)
        this.isTesting.set(true)
        setTimeout(() => this.isTesting.set(false), 500)
      }
    }
    
  • src/app/components/blocks/sso-identity-provider-config/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/sso-identity-provider-config.json