210 lines
6.9 KiB
Go
210 lines
6.9 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/ent/authidentity"
|
|
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
|
|
"github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
|
|
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestOIDCSyntheticEmailStableAndDistinct(t *testing.T) {
|
|
k1 := oidcIdentityKey("https://issuer.example.com", "subject-a")
|
|
k2 := oidcIdentityKey("https://issuer.example.com", "subject-b")
|
|
|
|
e1 := oidcSyntheticEmailFromIdentityKey(k1)
|
|
e1Again := oidcSyntheticEmailFromIdentityKey(k1)
|
|
e2 := oidcSyntheticEmailFromIdentityKey(k2)
|
|
|
|
require.Equal(t, e1, e1Again)
|
|
require.NotEqual(t, e1, e2)
|
|
require.Contains(t, e1, "@oidc-connect.invalid")
|
|
}
|
|
|
|
func TestBuildOIDCAuthorizeURLIncludesNonceAndPKCE(t *testing.T) {
|
|
cfg := config.OIDCConnectConfig{
|
|
AuthorizeURL: "https://issuer.example.com/auth",
|
|
ClientID: "cid",
|
|
Scopes: "openid email profile",
|
|
}
|
|
|
|
u, err := buildOIDCAuthorizeURL(cfg, "state123", "nonce123", "challenge123", "https://app.example.com/callback")
|
|
require.NoError(t, err)
|
|
require.Contains(t, u, "nonce=nonce123")
|
|
require.Contains(t, u, "code_challenge=challenge123")
|
|
require.Contains(t, u, "code_challenge_method=S256")
|
|
require.Contains(t, u, "scope=openid+email+profile")
|
|
}
|
|
|
|
func TestOIDCParseAndValidateIDToken(t *testing.T) {
|
|
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
require.NoError(t, err)
|
|
|
|
kid := "kid-1"
|
|
jwks := oidcJWKSet{Keys: []oidcJWK{buildRSAJWK(kid, &priv.PublicKey)}}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
require.NoError(t, json.NewEncoder(w).Encode(jwks))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
now := time.Now()
|
|
claims := oidcIDTokenClaims{
|
|
Nonce: "nonce-ok",
|
|
Azp: "client-1",
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
Issuer: "https://issuer.example.com",
|
|
Subject: "subject-1",
|
|
Audience: jwt.ClaimStrings{"client-1", "another-aud"},
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
NotBefore: jwt.NewNumericDate(now.Add(-30 * time.Second)),
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)),
|
|
},
|
|
}
|
|
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
|
tok.Header["kid"] = kid
|
|
signed, err := tok.SignedString(priv)
|
|
require.NoError(t, err)
|
|
|
|
cfg := config.OIDCConnectConfig{
|
|
ClientID: "client-1",
|
|
IssuerURL: "https://issuer.example.com",
|
|
JWKSURL: srv.URL,
|
|
AllowedSigningAlgs: "RS256",
|
|
ClockSkewSeconds: 120,
|
|
}
|
|
|
|
parsed, err := oidcParseAndValidateIDToken(context.Background(), cfg, signed, "nonce-ok")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "subject-1", parsed.Subject)
|
|
require.Equal(t, "https://issuer.example.com", parsed.Issuer)
|
|
|
|
_, err = oidcParseAndValidateIDToken(context.Background(), cfg, signed, "bad-nonce")
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestOIDCParseUserInfoIncludesSuggestedProfile(t *testing.T) {
|
|
cfg := config.OIDCConnectConfig{}
|
|
|
|
claims := oidcParseUserInfo(`{
|
|
"sub":"subject-1",
|
|
"preferred_username":"alice",
|
|
"name":"Alice Example",
|
|
"picture":"https://cdn.example/avatar.png",
|
|
"email":"alice@example.com",
|
|
"email_verified":true
|
|
}`, cfg)
|
|
|
|
require.Equal(t, "subject-1", claims.Subject)
|
|
require.Equal(t, "alice", claims.Username)
|
|
require.Equal(t, "Alice Example", claims.DisplayName)
|
|
require.Equal(t, "https://cdn.example/avatar.png", claims.AvatarURL)
|
|
require.NotNil(t, claims.EmailVerified)
|
|
require.True(t, *claims.EmailVerified)
|
|
}
|
|
|
|
func buildRSAJWK(kid string, pub *rsa.PublicKey) oidcJWK {
|
|
n := base64.RawURLEncoding.EncodeToString(pub.N.Bytes())
|
|
e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes())
|
|
return oidcJWK{
|
|
Kty: "RSA",
|
|
Kid: kid,
|
|
Use: "sig",
|
|
Alg: "RS256",
|
|
N: n,
|
|
E: e,
|
|
}
|
|
}
|
|
|
|
func TestCompleteOIDCOAuthRegistrationAppliesPendingAdoptionDecision(t *testing.T) {
|
|
handler, client := newOAuthPendingFlowTestHandler(t, false)
|
|
ctx := context.Background()
|
|
|
|
session, err := client.PendingAuthSession.Create().
|
|
SetSessionToken("oidc-complete-session").
|
|
SetIntent("login").
|
|
SetProviderType("oidc").
|
|
SetProviderKey("https://issuer.example.com").
|
|
SetProviderSubject("oidc-subject-1").
|
|
SetResolvedEmail("93a310f4c1944c5bbd2e246df1f76485@oidc-connect.invalid").
|
|
SetBrowserSessionKey("oidc-browser").
|
|
SetUpstreamIdentityClaims(map[string]any{
|
|
"username": "oidc_user",
|
|
"issuer": "https://issuer.example.com",
|
|
"suggested_display_name": "OIDC Display",
|
|
"suggested_avatar_url": "https://cdn.example/oidc.png",
|
|
}).
|
|
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
|
|
Save(ctx)
|
|
require.NoError(t, err)
|
|
|
|
_, err = service.NewAuthPendingIdentityService(client).UpsertAdoptionDecision(ctx, service.PendingIdentityAdoptionDecisionInput{
|
|
PendingAuthSessionID: session.ID,
|
|
AdoptAvatar: true,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
body := bytes.NewBufferString(`{"invitation_code":"invite-1","adopt_display_name":true}`)
|
|
recorder := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(recorder)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/complete-registration", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
|
|
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("oidc-browser")})
|
|
c.Request = req
|
|
|
|
handler.CompleteOIDCOAuthRegistration(c)
|
|
|
|
require.Equal(t, http.StatusOK, recorder.Code)
|
|
responseData := decodeJSONBody(t, recorder)
|
|
require.NotEmpty(t, responseData["access_token"])
|
|
|
|
userEntity, err := client.User.Query().
|
|
Where(dbuser.EmailEQ(session.ResolvedEmail)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "OIDC Display", userEntity.Username)
|
|
|
|
identity, err := client.AuthIdentity.Query().
|
|
Where(
|
|
authidentity.ProviderTypeEQ("oidc"),
|
|
authidentity.ProviderKeyEQ("https://issuer.example.com"),
|
|
authidentity.ProviderSubjectEQ("oidc-subject-1"),
|
|
).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, userEntity.ID, identity.UserID)
|
|
require.Equal(t, "OIDC Display", identity.Metadata["display_name"])
|
|
require.Equal(t, "https://cdn.example/oidc.png", identity.Metadata["avatar_url"])
|
|
|
|
decision, err := client.IdentityAdoptionDecision.Query().
|
|
Where(identityadoptiondecision.PendingAuthSessionIDEQ(session.ID)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, decision.IdentityID)
|
|
require.Equal(t, identity.ID, *decision.IdentityID)
|
|
require.True(t, decision.AdoptDisplayName)
|
|
require.True(t, decision.AdoptAvatar)
|
|
|
|
consumed, err := client.PendingAuthSession.Query().
|
|
Where(pendingauthsession.IDEQ(session.ID)).
|
|
Only(ctx)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, consumed.ConsumedAt)
|
|
}
|