Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions server/app/servers.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,12 @@ func (app *CortezaApp) mountHttpRoutes(r chi.Router) {
}()

func() {
// Mount OIDC Discovery document at root level for backward compatibility
r.Handle("/.well-known/openid-configuration", app.AuthService.WellKnownOpenIDConfiguration())

// Also mount at /auth/.well-known/openid-configuration for OIDC compliance
// Per OIDC Discovery 1.0, the well-known endpoint must be at {issuer}/.well-known/openid-configuration
// Since our issuer URL is {baseURL}/auth, clients need to discover at /auth/.well-known/...
r.Handle("/auth/.well-known/openid-configuration", app.AuthService.WellKnownOpenIDConfiguration())
}()
}
93 changes: 84 additions & 9 deletions server/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -436,19 +436,94 @@ func dirCheck(path string) (err error) {
return
}

// WellKnownOpenIDConfiguration returns the OIDC Discovery document
// per OpenID Connect Discovery 1.0 specification
func (svc service) WellKnownOpenIDConfiguration() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"issuer": svc.opt.BaseURL,
"authorization_endpoint": svc.opt.BaseURL + "/oauth2/authorize",
"token_endpoint": svc.opt.BaseURL + "/oauth2/token",
"jwks_uri": svc.opt.BaseURL + "/oauth2/public-keys",
"scope_supported": []string{"profile", "api"},
"id_token_signing_alg_values_supported": []string{"RS256", "HS512"},
"response_types_supported": []string{"code", "token"},
})
// AUTH_BASE_URL defaults to FullURL("/auth"), which includes the /auth suffix
// We need to strip this suffix because the endpoint paths already include /auth prefix
baseURL := strings.TrimSuffix(svc.opt.BaseURL, "/")
baseURL = strings.TrimSuffix(baseURL, "/auth")

// OIDC Discovery spec requires issuer URL to match the path where discovery was accessed
// minus the /.well-known/openid-configuration suffix.
// If accessed at /auth/.well-known/openid-configuration, issuer should include /auth
issuer := baseURL
if strings.HasPrefix(r.URL.Path, "/auth/") {
issuer = baseURL + "/auth"
}

config := map[string]interface{}{
// Required fields (OIDC Discovery 1.0 Section 3)
"issuer": issuer,
"authorization_endpoint": baseURL + "/auth/oauth2/authorize",
"token_endpoint": baseURL + "/auth/oauth2/token",
"jwks_uri": baseURL + "/auth/oauth2/public-keys",

// UserInfo endpoint (critical for OIDC clients)
"userinfo_endpoint": baseURL + "/auth/oauth2/userinfo",

// Response types supported
"response_types_supported": []string{"code"},

// Subject types
"subject_types_supported": []string{"public"},

// ID Token signing algorithms
"id_token_signing_alg_values_supported": []string{"HS512"},

// Token endpoint authentication methods
"token_endpoint_auth_methods_supported": []string{
"client_secret_basic",
"client_secret_post",
},

// Scopes supported (note: correct field name is scopes_supported, not scope_supported)
"scopes_supported": []string{
"openid",
"profile",
"email",
"api",
},

// Claims supported
"claims_supported": []string{
"sub",
"iss",
"aud",
"exp",
"iat",
"name",
"given_name",
"family_name",
"preferred_username",
"email",
"email_verified",
"picture",
"locale",
"updated_at",
},

// Code challenge methods (PKCE)
"code_challenge_methods_supported": []string{"plain", "S256"},

// Grant types supported
"grant_types_supported": []string{
"authorization_code",
"refresh_token",
"client_credentials",
},

// Prompt values supported (OIDC Core 1.0 Section 3.1.2.1)
// "none" enables silent authentication checks without UI
// "login" forces re-authentication
// "consent" forces consent screen display
"prompt_values_supported": []string{"none", "login", "consent"},
}

w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=3600")
_ = json.NewEncoder(w).Encode(config)
}
}

Expand Down
207 changes: 198 additions & 9 deletions server/auth/handlers/handle_oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -539,35 +539,224 @@ func SubSplit(ti oauth2def.TokenInfo, data map[string]interface{}) {
}
}

// Generates ID token that is part of OIDC flow for doing corteza-to-corteza auth
// Generates ID token that is part of OIDC flow (OIDC Core 1.0 Section 2)
// Returns a JWT with standard OIDC claims
func generateIdToken(user *types.User, client *types.AuthClient, ti oauth2def.TokenInfo, baseURL string) (_ []byte, err error) {
token := jwt.New()

// Required claims (OIDC Core 1.0 Section 2)
if err = token.Set(jwt.IssuerKey, baseURL); err != nil {
return
}

// we do not know what the admin used for client key value
// on the receiving end, so we'll encode both,
// client's ID, and it's handle
// 'sub' - Subject Identifier (REQUIRED)
// Using user ID as the subject - this is stable and unique
if err = token.Set(jwt.SubjectKey, strconv.FormatUint(user.ID, 10)); err != nil {
return
}

// 'aud' - Audience (REQUIRED)
// Include both client ID and handle so receiving end can match either
aud := []string{strconv.FormatUint(client.ID, 10)}
if len(client.Handle) > 0 {
aud = append(aud, client.Handle)
}
if err = token.Set(jwt.AudienceKey, aud); err != nil {
return
}

if err = token.Set("aud", aud); err != nil {
// 'exp' - Expiration time (REQUIRED)
if err = token.Set(jwt.ExpirationKey, now().Add(ti.GetAccessExpiresIn()).Unix()); err != nil {
return
}
if err = token.Set("user_id", strconv.FormatUint(user.ID, 10)); err != nil {

// 'iat' - Issued at (REQUIRED)
if err = token.Set(jwt.IssuedAtKey, now().Unix()); err != nil {
return
}
if err = token.Set("email", user.Email); err != nil {

// Standard claims based on scope
scope := ti.GetScope()

// Profile claims
if strings.Contains(scope, "profile") || strings.Contains(scope, "openid") {
if user.Name != "" {
if err = token.Set("name", user.Name); err != nil {
return
}
// Try to extract given_name and family_name
nameParts := strings.SplitN(user.Name, " ", 2)
if len(nameParts) >= 1 {
_ = token.Set("given_name", nameParts[0])
}
if len(nameParts) >= 2 {
_ = token.Set("family_name", nameParts[1])
}
}

if user.Handle != "" {
if err = token.Set("preferred_username", user.Handle); err != nil {
return
}
}

// Picture claim
if user.Meta != nil && user.Meta.AvatarID != 0 {
pictureURL := fmt.Sprintf("%s/api/system/attachment/avatar/%d/original/avatar.png",
strings.TrimSuffix(baseURL, "/"),
user.Meta.AvatarID)
_ = token.Set("picture", pictureURL)
}

// updated_at claim
if user.UpdatedAt != nil {
_ = token.Set("updated_at", user.UpdatedAt.Unix())
}

// locale claim (Corteza's preferred language)
if user.Meta != nil && user.Meta.PreferredLanguage != "" {
_ = token.Set("locale", user.Meta.PreferredLanguage)
}
}

// Email claims
if strings.Contains(scope, "email") || strings.Contains(scope, "openid") {
if user.Email != "" {
if err = token.Set("email", user.Email); err != nil {
return
}
if err = token.Set("email_verified", user.EmailConfirmed); err != nil {
return
}
}
}

return jwt.Sign(token, jwa.HS512, []byte(client.Secret))
}

// oauth2Userinfo implements the OIDC UserInfo endpoint (OIDC Core 1.0 Section 5.3)
// Returns claims about the authenticated user based on the granted scopes
func (h AuthHandlers) oauth2Userinfo(w http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
jt jwt.Token
)

err := func() (err error) {
// Extract token from context (set by HttpTokenVerifier middleware)
if jt, _, err = jwtauth.FromContext(ctx); err != nil {
return
}

if jt == nil {
return fmt.Errorf("no token found")
}

// Validate the token
if err = auth.TokenIssuer.Validate(ctx, jt); err != nil {
return
}

return nil
}()

if err != nil {
h.userinfoError(w, err)
return
}
if err = token.Set(jwt.ExpirationKey, now().Add(ti.GetAccessExpiresIn()).Unix()); err != nil {

// Extract user ID from the subject claim
subClaim, ok := jt.Get("sub")
if !ok {
h.userinfoError(w, fmt.Errorf("missing sub claim"))
return
}

return jwt.Sign(token, jwa.HS512, []byte(client.Secret))
userID, _ := auth.ExtractFromSubClaim(cast.ToString(subClaim))
if userID == 0 {
h.userinfoError(w, fmt.Errorf("invalid user ID in sub claim"))
return
}

// Get scope from token
scopeClaim, _ := jt.Get("scope")
scope := cast.ToString(scopeClaim)

// Load user
suCtx := auth.SetIdentityToContext(ctx, auth.ServiceUser())
user, err := h.UserService.FindByAny(suCtx, userID)
if err != nil {
h.userinfoError(w, fmt.Errorf("user not found: %w", err))
return
}

// Build response with OIDC standard claims
response := make(map[string]interface{})

// 'sub' claim is always required (OIDC Core 1.0 Section 5.1)
response["sub"] = strconv.FormatUint(user.ID, 10)

// 'profile' scope claims (OIDC Core 1.0 Section 5.4)
if auth.CheckScope(scope, "profile", "openid") {
if user.Name != "" {
response["name"] = user.Name
// Try to split name into given_name and family_name
nameParts := strings.SplitN(user.Name, " ", 2)
if len(nameParts) >= 1 {
response["given_name"] = nameParts[0]
}
if len(nameParts) >= 2 {
response["family_name"] = nameParts[1]
}
}

if user.Handle != "" {
response["preferred_username"] = user.Handle
}

// Picture claim - construct URL to avatar if available
if user.Meta != nil && user.Meta.AvatarID != 0 {
response["picture"] = fmt.Sprintf("%s/api/system/attachment/avatar/%d/original/avatar.png",
strings.TrimSuffix(h.Opt.BaseURL, "/"),
user.Meta.AvatarID)
}

// updated_at claim (seconds since Unix epoch)
if user.UpdatedAt != nil {
response["updated_at"] = user.UpdatedAt.Unix()
} else {
response["updated_at"] = user.CreatedAt.Unix()
}

// Corteza-specific: preferred language
if user.Meta != nil && user.Meta.PreferredLanguage != "" {
response["locale"] = user.Meta.PreferredLanguage
}
}

// 'email' scope claims (OIDC Core 1.0 Section 5.4)
if auth.CheckScope(scope, "email", "openid") {
if user.Email != "" {
response["email"] = user.Email
response["email_verified"] = user.EmailConfirmed
}
}

w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(response)
}

// userinfoError returns an error response for the userinfo endpoint
func (h AuthHandlers) userinfoError(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"error": "invalid_token",
"error_description": err.Error(),
})
}

func writeResponse(w http.ResponseWriter, data map[string]interface{}, header http.Header, statusCode ...int) error {
Expand Down
Loading
Loading