diff --git a/example.env b/example.env index b98824643..ada9b8392 100644 --- a/example.env +++ b/example.env @@ -144,6 +144,16 @@ GOTRUE_EXTERNAL_NOTION_CLIENT_ID="" GOTRUE_EXTERNAL_NOTION_SECRET="" GOTRUE_EXTERNAL_NOTION_REDIRECT_URI="https://localhost:9999/callback" +# NHS CIS2 OAuth config (UK National Health Service Care Identity Service 2) +# Documentation: https://digital.nhs.uk/services/care-identity-service +GOTRUE_EXTERNAL_NHS_CIS2_ENABLED="false" +GOTRUE_EXTERNAL_NHS_CIS2_CLIENT_ID="" +GOTRUE_EXTERNAL_NHS_CIS2_SECRET="" +GOTRUE_EXTERNAL_NHS_CIS2_REDIRECT_URI="http://localhost:9999/callback" +# Production: https://am.nhsidentity.spineservices.nhs.uk +# Integration: https://am.nhsint.auth-ptl.cis2.spineservices.nhs.uk +GOTRUE_EXTERNAL_NHS_CIS2_URL="https://am.nhsidentity.spineservices.nhs.uk" + # Twitter (X) OAuth1 config GOTRUE_EXTERNAL_TWITTER_ENABLED="false" GOTRUE_EXTERNAL_TWITTER_CLIENT_ID="" diff --git a/internal/api/external.go b/internal/api/external.go index 8392797d5..bb9d01643 100644 --- a/internal/api/external.go +++ b/internal/api/external.go @@ -668,6 +668,9 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide case "notion": pConfig = config.External.Notion p, err = provider.NewNotionProvider(pConfig) + case "nhs_cis2": + pConfig = config.External.NHSCIS2 + p, err = provider.NewNHSCIS2Provider(pConfig, scopes) case "snapchat": pConfig = config.External.Snapchat p, err = provider.NewSnapchatProvider(pConfig, scopes) diff --git a/internal/api/external_nhs_cis2_test.go b/internal/api/external_nhs_cis2_test.go new file mode 100644 index 000000000..70436c80d --- /dev/null +++ b/internal/api/external_nhs_cis2_test.go @@ -0,0 +1,223 @@ +package api + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + + jwt "github.com/golang-jwt/jwt/v5" + "github.com/supabase/auth/internal/models" +) + +const ( + nhsCIS2User string = `{"sub": "nhscis2testid", "name": "Dr John Smith", "given_name": "John", "family_name": "Smith", "email": "john.smith@nhs.net", "preferred_username": "jsmith", "email_verified": true, "uid": "123456789012", "nhsid_nrbac_roles": "R8000:G8000:R8001", "id_assurance_level": "3"}` + nhsCIS2UserNoEmail string = `{"sub": "nhscis2testid", "name": "Dr John Smith", "preferred_username": "jsmith", "email_verified": false}` + nhsCIS2UserUnverified string = `{"sub": "nhscis2testid", "name": "Dr Jane Doe", "email": "jane.doe@nhs.net", "email_verified": false}` +) + +func (ts *ExternalTestSuite) TestSignupExternalNHSCIS2() { + req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=nhs_cis2", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.Require().Equal(http.StatusFound, w.Code) + u, err := url.Parse(w.Header().Get("Location")) + ts.Require().NoError(err, "redirect url parse failed") + q := u.Query() + ts.Equal(ts.Config.External.NHSCIS2.RedirectURI, q.Get("redirect_uri")) + ts.Equal(ts.Config.External.NHSCIS2.ClientID, []string{q.Get("client_id")}) + ts.Equal("code", q.Get("response_type")) + ts.Equal("openid profile email", q.Get("scope")) + + claims := ExternalProviderClaims{} + p := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name})) + _, err = p.ParseWithClaims(q.Get("state"), &claims, func(token *jwt.Token) (interface{}, error) { + return []byte(ts.Config.JWT.Secret), nil + }) + ts.Require().NoError(err) + + ts.Equal("nhs_cis2", claims.Provider) + ts.Equal(ts.Config.SiteURL, claims.SiteURL) +} + +func NHSCIS2TestSignupSetup(ts *ExternalTestSuite, tokenCount *int, userCount *int, code string, user string) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/openam/oauth2/realms/root/realms/NHSIdentity/realms/Healthcare/access_token": + *tokenCount++ + ts.Equal(code, r.FormValue("code")) + ts.Equal("authorization_code", r.FormValue("grant_type")) + ts.Equal(ts.Config.External.NHSCIS2.RedirectURI, r.FormValue("redirect_uri")) + + w.Header().Add("Content-Type", "application/json") + fmt.Fprint(w, `{"access_token":"nhs_cis2_token","expires_in":100000}`) + case "/openam/oauth2/realms/root/realms/NHSIdentity/realms/Healthcare/userinfo": + *userCount++ + w.Header().Add("Content-Type", "application/json") + fmt.Fprint(w, user) + default: + w.WriteHeader(500) + ts.Fail("unknown NHS CIS2 oauth call %s", r.URL.Path) + } + })) + + ts.Config.External.NHSCIS2.URL = server.URL + + return server +} + +func (ts *ExternalTestSuite) TestSignupExternalNHSCIS2WithoutURLSetup() { + ts.createUser("nhscis2testid", "john.smith@nhs.net", "Dr John Smith", "", "") + tokenCount, userCount := 0, 0 + code := "authcode" + server := NHSCIS2TestSignupSetup(ts, &tokenCount, &userCount, code, nhsCIS2User) + ts.Config.External.NHSCIS2.URL = "" + defer server.Close() + + w := performAuthorizationRequest(ts, "nhs_cis2", code) + ts.Equal(w.Code, http.StatusBadRequest) +} + +func (ts *ExternalTestSuite) TestSignupExternalNHSCIS2_AuthorizationCode() { + ts.Config.DisableSignup = false + ts.createUser("nhscis2testid", "john.smith@nhs.net", "Dr John Smith", "", "") + tokenCount, userCount := 0, 0 + code := "authcode" + server := NHSCIS2TestSignupSetup(ts, &tokenCount, &userCount, code, nhsCIS2User) + defer server.Close() + + u := performAuthorization(ts, "nhs_cis2", code, "") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "john.smith@nhs.net", "Dr John Smith", "nhscis2testid", "") +} + +func (ts *ExternalTestSuite) TestSignupExternalNHSCIS2DisableSignupErrorWhenNoUser() { + ts.Config.DisableSignup = true + tokenCount, userCount := 0, 0 + code := "authcode" + server := NHSCIS2TestSignupSetup(ts, &tokenCount, &userCount, code, nhsCIS2User) + defer server.Close() + + u := performAuthorization(ts, "nhs_cis2", code, "") + + assertAuthorizationFailure(ts, u, "Signups not allowed for this instance", "access_denied", "john.smith@nhs.net") +} + +func (ts *ExternalTestSuite) TestSignupExternalNHSCIS2DisableSignupErrorWhenNoEmail() { + ts.Config.DisableSignup = true + tokenCount, userCount := 0, 0 + code := "authcode" + server := NHSCIS2TestSignupSetup(ts, &tokenCount, &userCount, code, nhsCIS2UserNoEmail) + defer server.Close() + + u := performAuthorization(ts, "nhs_cis2", code, "") + + assertAuthorizationFailure(ts, u, "Error getting user email from external provider", "server_error", "john.smith@nhs.net") +} + +func (ts *ExternalTestSuite) TestSignupExternalNHSCIS2DisableSignupSuccessWithPrimaryEmail() { + ts.Config.DisableSignup = true + + ts.createUser("nhscis2testid", "john.smith@nhs.net", "Dr John Smith", "", "") + + tokenCount, userCount := 0, 0 + code := "authcode" + server := NHSCIS2TestSignupSetup(ts, &tokenCount, &userCount, code, nhsCIS2User) + defer server.Close() + + u := performAuthorization(ts, "nhs_cis2", code, "") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "john.smith@nhs.net", "Dr John Smith", "nhscis2testid", "") +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalNHSCIS2SuccessWhenMatchingToken() { + // name should be populated from NHS CIS2 API + ts.createUser("nhscis2testid", "john.smith@nhs.net", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + server := NHSCIS2TestSignupSetup(ts, &tokenCount, &userCount, code, nhsCIS2User) + defer server.Close() + + u := performAuthorization(ts, "nhs_cis2", code, "invite_token") + + assertAuthorizationSuccess(ts, u, tokenCount, userCount, "john.smith@nhs.net", "Dr John Smith", "nhscis2testid", "") +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalNHSCIS2ErrorWhenNoMatchingToken() { + tokenCount, userCount := 0, 0 + code := "authcode" + nhsCIS2UserData := `{"name":"Dr John Smith"}` + server := NHSCIS2TestSignupSetup(ts, &tokenCount, &userCount, code, nhsCIS2UserData) + defer server.Close() + + w := performAuthorizationRequest(ts, "nhs_cis2", "invite_token") + ts.Require().Equal(http.StatusNotFound, w.Code) +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalNHSCIS2ErrorWhenWrongToken() { + ts.createUser("nhscis2testid", "john.smith@nhs.net", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + nhsCIS2UserData := `{"name":"Dr John Smith"}` + server := NHSCIS2TestSignupSetup(ts, &tokenCount, &userCount, code, nhsCIS2UserData) + defer server.Close() + + w := performAuthorizationRequest(ts, "nhs_cis2", "wrong_token") + ts.Require().Equal(http.StatusNotFound, w.Code) +} + +func (ts *ExternalTestSuite) TestInviteTokenExternalNHSCIS2ErrorWhenEmailDoesntMatch() { + ts.createUser("nhscis2testid", "john.smith@nhs.net", "", "", "invite_token") + + tokenCount, userCount := 0, 0 + code := "authcode" + nhsCIS2UserData := `{"name":"Dr Jane Doe", "email":"other@nhs.net"}` + server := NHSCIS2TestSignupSetup(ts, &tokenCount, &userCount, code, nhsCIS2UserData) + defer server.Close() + + u := performAuthorization(ts, "nhs_cis2", code, "invite_token") + + assertAuthorizationFailure(ts, u, "Invited email does not match emails from external provider", "invalid_request", "") +} + +func (ts *ExternalTestSuite) TestSignupExternalNHSCIS2WithCustomScopes() { + // Test that custom scopes are properly appended + req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=nhs_cis2&scopes=nationalrbacaccess,associatedorgs", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.Require().Equal(http.StatusFound, w.Code) + u, err := url.Parse(w.Header().Get("Location")) + ts.Require().NoError(err, "redirect url parse failed") + q := u.Query() + scope := q.Get("scope") + ts.Contains(scope, "openid") + ts.Contains(scope, "profile") + ts.Contains(scope, "email") + ts.Contains(scope, "nationalrbacaccess") + ts.Contains(scope, "associatedorgs") +} + +func (ts *ExternalTestSuite) TestSignupExternalNHSCIS2_PreservesNHSClaims() { + ts.Config.DisableSignup = false + tokenCount, userCount := 0, 0 + code := "authcode" + server := NHSCIS2TestSignupSetup(ts, &tokenCount, &userCount, code, nhsCIS2User) + defer server.Close() + + u := performAuthorization(ts, "nhs_cis2", code, "") + + // Verify authorization was successful + v, err := url.ParseQuery(u.RawQuery) + ts.Require().NoError(err) + ts.Require().Empty(v.Get("error_description")) + ts.Require().Empty(v.Get("error")) + + // Check that user was created with NHS-specific metadata + user, err := models.FindUserByEmailAndAudience(ts.API.db, "john.smith@nhs.net", ts.Config.JWT.Aud) + ts.Require().NoError(err) + ts.NotNil(user) + ts.Equal("nhscis2testid", user.UserMetaData["provider_id"]) + ts.Equal("Dr John Smith", user.UserMetaData["full_name"]) +} diff --git a/internal/api/provider/nhs_cis2.go b/internal/api/provider/nhs_cis2.go new file mode 100644 index 000000000..67102028b --- /dev/null +++ b/internal/api/provider/nhs_cis2.go @@ -0,0 +1,187 @@ +package provider + +import ( + "context" + "encoding/json" + "strings" + + "github.com/supabase/auth/internal/conf" + "golang.org/x/oauth2" +) + +const ( + defaultNHSCIS2Host = "am.nhsidentity.spineservices.nhs.uk" + // NHS CIS2 OIDC paths + nhsCIS2AuthPath = "/openam/oauth2/realms/root/realms/NHSIdentity/realms/Healthcare/authorize" + nhsCIS2TokenPath = "/openam/oauth2/realms/root/realms/NHSIdentity/realms/Healthcare/access_token" + nhsCIS2UserInfoPath = "/openam/oauth2/realms/root/realms/NHSIdentity/realms/Healthcare/userinfo" +) + +// nhsCIS2Provider implements the OAuthProvider interface for NHS Care Identity Service 2 (CIS2) +type nhsCIS2Provider struct { + *oauth2.Config + Host string +} + +// nhsCIS2User represents the user data returned from NHS CIS2 userinfo endpoint +type nhsCIS2User struct { + Sub string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` + PreferredUsername string `json:"preferred_username"` + UID string `json:"uid"` + NHSNumber string `json:"nhsid_nrbac_roles,omitempty"` + IDAssuranceLevel string `json:"id_assurance_level,omitempty"` + AuthenticationLevel string `json:"authentication_assurance_level,omitempty"` + OrganizationCode string `json:"selected_roleid,omitempty"` + RawClaims map[string]interface{} `json:"-"` +} + +func (u *nhsCIS2User) UnmarshalJSON(data []byte) error { + if err := json.Unmarshal(data, &u.RawClaims); err != nil { + return err + } + + // Extract known fields + if v, ok := u.RawClaims["sub"].(string); ok { + u.Sub = v + } + if v, ok := u.RawClaims["email"].(string); ok { + u.Email = v + } + if v, ok := u.RawClaims["email_verified"].(bool); ok { + u.EmailVerified = v + } + if v, ok := u.RawClaims["name"].(string); ok { + u.Name = v + } + if v, ok := u.RawClaims["given_name"].(string); ok { + u.GivenName = v + } + if v, ok := u.RawClaims["family_name"].(string); ok { + u.FamilyName = v + } + if v, ok := u.RawClaims["preferred_username"].(string); ok { + u.PreferredUsername = v + } + if v, ok := u.RawClaims["uid"].(string); ok { + u.UID = v + } + if v, ok := u.RawClaims["nhsid_nrbac_roles"].(string); ok { + u.NHSNumber = v + } + if v, ok := u.RawClaims["id_assurance_level"].(string); ok { + u.IDAssuranceLevel = v + } + if v, ok := u.RawClaims["authentication_assurance_level"].(string); ok { + u.AuthenticationLevel = v + } + if v, ok := u.RawClaims["selected_roleid"].(string); ok { + u.OrganizationCode = v + } + + return nil +} + +// NewNHSCIS2Provider creates a new NHS CIS2 OAuth provider +func NewNHSCIS2Provider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) { + if err := ext.ValidateOAuth(); err != nil { + return nil, err + } + + oauthScopes := []string{ + "openid", + "profile", + "email", + } + + if scopes != "" { + oauthScopes = append(oauthScopes, strings.Split(scopes, ",")...) + } + + host := chooseHost(ext.URL, defaultNHSCIS2Host) + + return &nhsCIS2Provider{ + Config: &oauth2.Config{ + ClientID: ext.ClientID[0], + ClientSecret: ext.Secret, + Endpoint: oauth2.Endpoint{ + AuthURL: host + nhsCIS2AuthPath, + TokenURL: host + nhsCIS2TokenPath, + }, + RedirectURL: ext.RedirectURI, + Scopes: oauthScopes, + }, + Host: host, + }, nil +} + +// GetOAuthToken exchanges an authorization code for an OAuth token +func (p nhsCIS2Provider) GetOAuthToken(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return p.Exchange(ctx, code, opts...) +} + +// RequiresPKCE returns whether this provider requires PKCE +func (p nhsCIS2Provider) RequiresPKCE() bool { + return false +} + +// GetUserData fetches user data from the NHS CIS2 userinfo endpoint +func (p nhsCIS2Provider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { + var u nhsCIS2User + + if err := makeRequest(ctx, tok, p.Config, p.Host+nhsCIS2UserInfoPath, &u); err != nil { + return nil, err + } + + data := &UserProvidedData{} + + if u.Email != "" { + data.Emails = []Email{{ + Email: u.Email, + Verified: u.EmailVerified, + Primary: true, + }} + } + + // Build custom claims from NHS-specific data + customClaims := make(map[string]interface{}) + standardClaims := map[string]bool{ + "sub": true, "email": true, "email_verified": true, + "name": true, "given_name": true, "family_name": true, + "preferred_username": true, + } + + for k, v := range u.RawClaims { + if !standardClaims[k] { + customClaims[k] = v + } + } + + // Construct full name if not provided + fullName := u.Name + if fullName == "" && (u.GivenName != "" || u.FamilyName != "") { + fullName = strings.TrimSpace(u.GivenName + " " + u.FamilyName) + } + + data.Metadata = &Claims{ + Issuer: p.Host, + Subject: u.Sub, + Name: fullName, + GivenName: u.GivenName, + FamilyName: u.FamilyName, + PreferredUsername: u.PreferredUsername, + Email: u.Email, + EmailVerified: u.EmailVerified, + CustomClaims: customClaims, + + // To be deprecated + FullName: fullName, + ProviderId: u.Sub, + } + + return data, nil +} diff --git a/internal/api/provider/nhs_cis2_test.go b/internal/api/provider/nhs_cis2_test.go new file mode 100644 index 000000000..00f83cf50 --- /dev/null +++ b/internal/api/provider/nhs_cis2_test.go @@ -0,0 +1,280 @@ +package provider + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/conf" +) + +func TestNewNHSCIS2Provider(t *testing.T) { + t.Run("valid configuration", func(t *testing.T) { + cfg := conf.OAuthProviderConfiguration{ + ClientID: []string{"test-client-id"}, + Secret: "test-secret", + RedirectURI: "https://example.com/callback", + URL: "https://am.nhsidentity.spineservices.nhs.uk", + Enabled: true, + } + + p, err := NewNHSCIS2Provider(cfg, "") + require.NoError(t, err) + require.NotNil(t, p) + + provider := p.(*nhsCIS2Provider) + assert.Equal(t, "test-client-id", provider.Config.ClientID) + assert.Equal(t, "test-secret", provider.Config.ClientSecret) + assert.Equal(t, "https://example.com/callback", provider.Config.RedirectURL) + assert.Contains(t, provider.Config.Scopes, "openid") + assert.Contains(t, provider.Config.Scopes, "profile") + assert.Contains(t, provider.Config.Scopes, "email") + }) + + t.Run("valid configuration with custom scopes", func(t *testing.T) { + cfg := conf.OAuthProviderConfiguration{ + ClientID: []string{"test-client-id"}, + Secret: "test-secret", + RedirectURI: "https://example.com/callback", + URL: "https://am.nhsidentity.spineservices.nhs.uk", + Enabled: true, + } + + p, err := NewNHSCIS2Provider(cfg, "nationalrbacaccess,associatedorgs") + require.NoError(t, err) + require.NotNil(t, p) + + provider := p.(*nhsCIS2Provider) + assert.Contains(t, provider.Config.Scopes, "openid") + assert.Contains(t, provider.Config.Scopes, "profile") + assert.Contains(t, provider.Config.Scopes, "email") + assert.Contains(t, provider.Config.Scopes, "nationalrbacaccess") + assert.Contains(t, provider.Config.Scopes, "associatedorgs") + }) + + t.Run("default host when URL not provided", func(t *testing.T) { + cfg := conf.OAuthProviderConfiguration{ + ClientID: []string{"test-client-id"}, + Secret: "test-secret", + RedirectURI: "https://example.com/callback", + URL: "", + Enabled: true, + } + + p, err := NewNHSCIS2Provider(cfg, "") + require.NoError(t, err) + require.NotNil(t, p) + + provider := p.(*nhsCIS2Provider) + assert.Equal(t, "https://"+defaultNHSCIS2Host, provider.Host) + }) + + t.Run("custom host URL", func(t *testing.T) { + cfg := conf.OAuthProviderConfiguration{ + ClientID: []string{"test-client-id"}, + Secret: "test-secret", + RedirectURI: "https://example.com/callback", + URL: "https://custom.nhs.example.com", + Enabled: true, + } + + p, err := NewNHSCIS2Provider(cfg, "") + require.NoError(t, err) + require.NotNil(t, p) + + provider := p.(*nhsCIS2Provider) + assert.Equal(t, "https://custom.nhs.example.com", provider.Host) + }) + + t.Run("missing client ID", func(t *testing.T) { + cfg := conf.OAuthProviderConfiguration{ + ClientID: []string{}, + Secret: "test-secret", + RedirectURI: "https://example.com/callback", + URL: "https://am.nhsidentity.spineservices.nhs.uk", + Enabled: true, + } + + p, err := NewNHSCIS2Provider(cfg, "") + require.Error(t, err) + require.Nil(t, p) + assert.Contains(t, err.Error(), "client ID") + }) + + t.Run("missing secret", func(t *testing.T) { + cfg := conf.OAuthProviderConfiguration{ + ClientID: []string{"test-client-id"}, + Secret: "", + RedirectURI: "https://example.com/callback", + URL: "https://am.nhsidentity.spineservices.nhs.uk", + Enabled: true, + } + + p, err := NewNHSCIS2Provider(cfg, "") + require.Error(t, err) + require.Nil(t, p) + assert.Contains(t, err.Error(), "secret") + }) + + t.Run("missing redirect URI", func(t *testing.T) { + cfg := conf.OAuthProviderConfiguration{ + ClientID: []string{"test-client-id"}, + Secret: "test-secret", + RedirectURI: "", + URL: "https://am.nhsidentity.spineservices.nhs.uk", + Enabled: true, + } + + p, err := NewNHSCIS2Provider(cfg, "") + require.Error(t, err) + require.Nil(t, p) + assert.Contains(t, err.Error(), "redirect") + }) + + t.Run("provider not enabled", func(t *testing.T) { + cfg := conf.OAuthProviderConfiguration{ + ClientID: []string{"test-client-id"}, + Secret: "test-secret", + RedirectURI: "https://example.com/callback", + URL: "https://am.nhsidentity.spineservices.nhs.uk", + Enabled: false, + } + + p, err := NewNHSCIS2Provider(cfg, "") + require.Error(t, err) + require.Nil(t, p) + assert.Contains(t, err.Error(), "not enabled") + }) + + t.Run("correct OAuth endpoints", func(t *testing.T) { + cfg := conf.OAuthProviderConfiguration{ + ClientID: []string{"test-client-id"}, + Secret: "test-secret", + RedirectURI: "https://example.com/callback", + URL: "https://am.nhsidentity.spineservices.nhs.uk", + Enabled: true, + } + + p, err := NewNHSCIS2Provider(cfg, "") + require.NoError(t, err) + require.NotNil(t, p) + + provider := p.(*nhsCIS2Provider) + expectedAuthURL := "https://am.nhsidentity.spineservices.nhs.uk" + nhsCIS2AuthPath + expectedTokenURL := "https://am.nhsidentity.spineservices.nhs.uk" + nhsCIS2TokenPath + + assert.Equal(t, expectedAuthURL, provider.Config.Endpoint.AuthURL) + assert.Equal(t, expectedTokenURL, provider.Config.Endpoint.TokenURL) + }) +} + +func TestNHSCIS2UserUnmarshalJSON(t *testing.T) { + t.Run("full user data", func(t *testing.T) { + jsonData := `{ + "sub": "user-123", + "email": "doctor@nhs.net", + "email_verified": true, + "name": "Dr John Smith", + "given_name": "John", + "family_name": "Smith", + "preferred_username": "jsmith", + "uid": "123456789012", + "nhsid_nrbac_roles": "R8000:G8000:R8001", + "id_assurance_level": "3", + "authentication_assurance_level": "2", + "selected_roleid": "555254240100" + }` + + var u nhsCIS2User + err := json.Unmarshal([]byte(jsonData), &u) + require.NoError(t, err) + + assert.Equal(t, "user-123", u.Sub) + assert.Equal(t, "doctor@nhs.net", u.Email) + assert.Equal(t, true, u.EmailVerified) + assert.Equal(t, "Dr John Smith", u.Name) + assert.Equal(t, "John", u.GivenName) + assert.Equal(t, "Smith", u.FamilyName) + assert.Equal(t, "jsmith", u.PreferredUsername) + assert.Equal(t, "123456789012", u.UID) + assert.Equal(t, "R8000:G8000:R8001", u.NHSNumber) + assert.Equal(t, "3", u.IDAssuranceLevel) + assert.Equal(t, "2", u.AuthenticationLevel) + assert.Equal(t, "555254240100", u.OrganizationCode) + }) + + t.Run("minimal user data", func(t *testing.T) { + jsonData := `{ + "sub": "user-456", + "email": "nurse@nhs.net" + }` + + var u nhsCIS2User + err := json.Unmarshal([]byte(jsonData), &u) + require.NoError(t, err) + + assert.Equal(t, "user-456", u.Sub) + assert.Equal(t, "nurse@nhs.net", u.Email) + assert.Equal(t, false, u.EmailVerified) + assert.Equal(t, "", u.Name) + assert.Equal(t, "", u.GivenName) + assert.Equal(t, "", u.FamilyName) + }) + + t.Run("preserves raw claims", func(t *testing.T) { + jsonData := `{ + "sub": "user-789", + "email": "admin@nhs.net", + "custom_claim_1": "value1", + "custom_claim_2": 42, + "nested_claim": {"key": "value"} + }` + + var u nhsCIS2User + err := json.Unmarshal([]byte(jsonData), &u) + require.NoError(t, err) + + assert.Equal(t, "user-789", u.Sub) + assert.Equal(t, "admin@nhs.net", u.Email) + assert.Equal(t, "value1", u.RawClaims["custom_claim_1"]) + assert.Equal(t, float64(42), u.RawClaims["custom_claim_2"]) + nested := u.RawClaims["nested_claim"].(map[string]interface{}) + assert.Equal(t, "value", nested["key"]) + }) + + t.Run("invalid JSON", func(t *testing.T) { + jsonData := `{invalid json}` + + var u nhsCIS2User + err := json.Unmarshal([]byte(jsonData), &u) + require.Error(t, err) + }) +} + +func TestNHSCIS2ProviderEndpoints(t *testing.T) { + t.Run("auth endpoint path", func(t *testing.T) { + assert.Equal(t, + "/openam/oauth2/realms/root/realms/NHSIdentity/realms/Healthcare/authorize", + nhsCIS2AuthPath, + ) + }) + + t.Run("token endpoint path", func(t *testing.T) { + assert.Equal(t, + "/openam/oauth2/realms/root/realms/NHSIdentity/realms/Healthcare/access_token", + nhsCIS2TokenPath, + ) + }) + + t.Run("userinfo endpoint path", func(t *testing.T) { + assert.Equal(t, + "/openam/oauth2/realms/root/realms/NHSIdentity/realms/Healthcare/userinfo", + nhsCIS2UserInfoPath, + ) + }) +} + +func TestNHSCIS2DefaultHost(t *testing.T) { + assert.Equal(t, "am.nhsidentity.spineservices.nhs.uk", defaultNHSCIS2Host) +} diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 3e397be69..aca07bb4f 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -430,6 +430,7 @@ type ProviderConfiguration struct { Keycloak OAuthProviderConfiguration `json:"keycloak"` Linkedin OAuthProviderConfiguration `json:"linkedin"` LinkedinOIDC OAuthProviderConfiguration `json:"linkedin_oidc" envconfig:"LINKEDIN_OIDC"` + NHSCIS2 OAuthProviderConfiguration `json:"nhs_cis2" envconfig:"NHS_CIS2"` Spotify OAuthProviderConfiguration `json:"spotify"` Slack OAuthProviderConfiguration `json:"slack"` SlackOIDC OAuthProviderConfiguration `json:"slack_oidc" envconfig:"SLACK_OIDC"`