diff --git a/pkg/server/api/connect/v1/gqlgen.yaml b/pkg/server/api/connect/v1/gqlgen.yaml index 75e4034ae..09a93ed38 100644 --- a/pkg/server/api/connect/v1/gqlgen.yaml +++ b/pkg/server/api/connect/v1/gqlgen.yaml @@ -1,5 +1,6 @@ schema: - "graphql/*.graphql" + - "../../../gqlutils/directives/authentication/schema.graphql" - "../../../gqlutils/directives/session/schema.graphql" exec: @@ -22,7 +23,9 @@ call_argument_directives_with_null: true directives: mustBeAuthorized: skip_runtime: false - session: + authentication: + skip_runtime: false + sessionOnly: skip_runtime: false models: diff --git a/pkg/server/api/connect/v1/graphql/audit_log.graphql b/pkg/server/api/connect/v1/graphql/audit_log.graphql index cfc88bda1..6dd724edc 100644 --- a/pkg/server/api/connect/v1/graphql/audit_log.graphql +++ b/pkg/server/api/connect/v1/graphql/audit_log.graphql @@ -54,7 +54,7 @@ type AuditLogEntry implements Node { permission(action: String!): Boolean! @goField(forceResolver: true) - @session(required: PRESENT) + @authentication(required: PRESENT) } type AuditLogEntryConnection diff --git a/pkg/server/api/connect/v1/graphql/base.graphql b/pkg/server/api/connect/v1/graphql/base.graphql index d1bac308a..896e0452c 100644 --- a/pkg/server/api/connect/v1/graphql/base.graphql +++ b/pkg/server/api/connect/v1/graphql/base.graphql @@ -21,17 +21,17 @@ interface Node { } type Query { - node(id: ID!): Node @session(required: PRESENT) - viewer: Identity @session(required: PRESENT) + node(id: ID!): Node @authentication(required: PRESENT) + viewer: Identity @authentication(required: PRESENT) ssoLoginURL(email: EmailAddr!): String @goField(forceResolver: true) - @session(required: OPTIONAL) + @authentication(required: OPTIONAL) oidcProviders: [OIDCProviderInfo!]! @goField(forceResolver: true) - @session(required: OPTIONAL) + @authentication(required: OPTIONAL) signUpEnabled: Boolean! @goField(forceResolver: true) - @session(required: OPTIONAL) + @authentication(required: OPTIONAL) } type OIDCProviderInfo { diff --git a/pkg/server/api/connect/v1/graphql/identity.graphql b/pkg/server/api/connect/v1/graphql/identity.graphql index 7382b4848..000db1a6e 100644 --- a/pkg/server/api/connect/v1/graphql/identity.graphql +++ b/pkg/server/api/connect/v1/graphql/identity.graphql @@ -28,17 +28,22 @@ type Identity implements Node { after: CursorKey last: Int before: CursorKey - ): PersonalAPIKeyConnection @goField(forceResolver: true) + ): PersonalAPIKeyConnection + @goField(forceResolver: true) + @authentication(required: PRESENT) + @sessionOnly invitingOrganizations: [Organization!]! @goField(forceResolver: true) - @session(required: PRESENT) + @authentication(required: PRESENT) + @sessionOnly ssoLoginURL: String @goField(forceResolver: true) - @session(required: PRESENT) + @authentication(required: PRESENT) + @sessionOnly permission(action: String!): Boolean! @goField(forceResolver: true) - @session(required: PRESENT) + @authentication(required: PRESENT) } diff --git a/pkg/server/api/connect/v1/graphql/invitation.graphql b/pkg/server/api/connect/v1/graphql/invitation.graphql index df0862389..d98e79b3e 100644 --- a/pkg/server/api/connect/v1/graphql/invitation.graphql +++ b/pkg/server/api/connect/v1/graphql/invitation.graphql @@ -7,7 +7,7 @@ type Invitation implements Node { permission(action: String!): Boolean! @goField(forceResolver: true) - @session(required: PRESENT) + @authentication(required: PRESENT) } enum InvitationStatus @@ -51,7 +51,7 @@ type InvitationEdge { extend type Mutation { inviteUser(input: InviteUserInput!): InviteUserPayload - @session(required: PRESENT) + @authentication(required: PRESENT) } input InviteUserInput { diff --git a/pkg/server/api/connect/v1/graphql/membership.graphql b/pkg/server/api/connect/v1/graphql/membership.graphql index 4310df3c6..f58cf4e8a 100644 --- a/pkg/server/api/connect/v1/graphql/membership.graphql +++ b/pkg/server/api/connect/v1/graphql/membership.graphql @@ -18,7 +18,7 @@ type Membership implements Node { permission(action: String!): Boolean! @goField(forceResolver: true) - @session(required: PRESENT) + @authentication(required: PRESENT) } extend type Mutation { diff --git a/pkg/server/api/connect/v1/graphql/oauth2.graphql b/pkg/server/api/connect/v1/graphql/oauth2.graphql index 768eb2f26..b341191ca 100644 --- a/pkg/server/api/connect/v1/graphql/oauth2.graphql +++ b/pkg/server/api/connect/v1/graphql/oauth2.graphql @@ -1,11 +1,11 @@ extend type Mutation { authorizeDevice( input: AuthorizeDeviceInput! - ): AuthorizeDevicePayload @session(required: PRESENT) + ): AuthorizeDevicePayload @authentication(required: PRESENT) @sessionOnly approveConsent( input: ApproveConsentInput! - ): ApproveConsentPayload @session(required: PRESENT) + ): ApproveConsentPayload @authentication(required: PRESENT) @sessionOnly } diff --git a/pkg/server/api/connect/v1/graphql/organization.graphql b/pkg/server/api/connect/v1/graphql/organization.graphql index 0247b1a4f..329641d30 100644 --- a/pkg/server/api/connect/v1/graphql/organization.graphql +++ b/pkg/server/api/connect/v1/graphql/organization.graphql @@ -41,22 +41,22 @@ type Organization implements Node { permission(action: String!): Boolean! @goField(forceResolver: true) - @session(required: PRESENT) + @authentication(required: PRESENT) } extend type Mutation { createOrganization( input: CreateOrganizationInput! - ): CreateOrganizationPayload @session(required: PRESENT) + ): CreateOrganizationPayload @authentication(required: PRESENT) updateOrganization( input: UpdateOrganizationInput! - ): UpdateOrganizationPayload @session(required: PRESENT) + ): UpdateOrganizationPayload @authentication(required: PRESENT) deleteOrganization( input: DeleteOrganizationInput! - ): DeleteOrganizationPayload @session(required: PRESENT) + ): DeleteOrganizationPayload @authentication(required: PRESENT) deleteOrganizationHorizontalLogo( input: DeleteOrganizationHorizontalLogoInput! - ): DeleteOrganizationHorizontalLogoPayload @session(required: PRESENT) + ): DeleteOrganizationHorizontalLogoPayload @authentication(required: PRESENT) } input CreateOrganizationInput { diff --git a/pkg/server/api/connect/v1/graphql/personal_api_key.graphql b/pkg/server/api/connect/v1/graphql/personal_api_key.graphql index 9ac535182..2136996fb 100644 --- a/pkg/server/api/connect/v1/graphql/personal_api_key.graphql +++ b/pkg/server/api/connect/v1/graphql/personal_api_key.graphql @@ -5,11 +5,15 @@ type PersonalAPIKey implements Node { lastUsedAt: Datetime createdAt: Datetime! - token: String @goField(forceResolver: true) + token: String + @goField(forceResolver: true) + @authentication(required: PRESENT) + @sessionOnly permission(action: String!): Boolean! @goField(forceResolver: true) - @session(required: PRESENT) + @authentication(required: PRESENT) + @sessionOnly } type PersonalAPIKeyConnection @@ -29,10 +33,14 @@ type PersonalAPIKeyEdge { extend type Mutation { createPersonalAPIKey( input: CreatePersonalAPIKeyInput! - ): CreatePersonalAPIKeyPayload @session(required: PRESENT) + ): CreatePersonalAPIKeyPayload + @authentication(required: PRESENT) + @sessionOnly revokePersonalAPIKey( input: RevokePersonalAPIKeyInput! - ): RevokePersonalAPIKeyPayload @session(required: PRESENT) + ): RevokePersonalAPIKeyPayload + @authentication(required: PRESENT) + @sessionOnly } input CreatePersonalAPIKeyInput { diff --git a/pkg/server/api/connect/v1/graphql/profile.graphql b/pkg/server/api/connect/v1/graphql/profile.graphql index 59c79b50f..fc1417868 100644 --- a/pkg/server/api/connect/v1/graphql/profile.graphql +++ b/pkg/server/api/connect/v1/graphql/profile.graphql @@ -25,7 +25,7 @@ type Profile implements Node { permission(action: String!): Boolean! @goField(forceResolver: true) - @session(required: PRESENT) + @authentication(required: PRESENT) } enum ProfileState @@ -97,13 +97,13 @@ type ProfileEdge { extend type Mutation { createUser(input: CreateUserInput!): CreateUserPayload - @session(required: PRESENT) + @authentication(required: PRESENT) deactivateUser(input: DeactivateUserInput!): DeactivateUserPayload updateUser(input: UpdateUserInput!): UpdateUserPayload! archiveUser(input: ArchiveUserInput!): ArchiveUserPayload - @session(required: PRESENT) + @authentication(required: PRESENT) removeUser(input: RemoveUserInput!): RemoveUserPayload - @session(required: PRESENT) + @authentication(required: PRESENT) } input CreateUserInput { diff --git a/pkg/server/api/connect/v1/graphql/saml.graphql b/pkg/server/api/connect/v1/graphql/saml.graphql index ece26a74e..c6af8a3cc 100644 --- a/pkg/server/api/connect/v1/graphql/saml.graphql +++ b/pkg/server/api/connect/v1/graphql/saml.graphql @@ -15,7 +15,7 @@ type SAMLConfiguration implements Node { permission(action: String!): Boolean! @goField(forceResolver: true) - @session(required: PRESENT) + @authentication(required: PRESENT) } type SAMLAttributeMappings { @@ -55,13 +55,13 @@ type SAMLConfigurationEdge { extend type Mutation { createSAMLConfiguration( input: CreateSAMLConfigurationInput! - ): CreateSAMLConfigurationPayload @session(required: PRESENT) + ): CreateSAMLConfigurationPayload @authentication(required: PRESENT) @sessionOnly updateSAMLConfiguration( input: UpdateSAMLConfigurationInput! - ): UpdateSAMLConfigurationPayload @session(required: PRESENT) + ): UpdateSAMLConfigurationPayload @authentication(required: PRESENT) @sessionOnly deleteSAMLConfiguration( input: DeleteSAMLConfigurationInput! - ): DeleteSAMLConfigurationPayload @session(required: PRESENT) + ): DeleteSAMLConfigurationPayload @authentication(required: PRESENT) @sessionOnly } input CreateSAMLConfigurationInput { diff --git a/pkg/server/api/connect/v1/graphql/scim.graphql b/pkg/server/api/connect/v1/graphql/scim.graphql index 0daa19151..5afd2ed69 100644 --- a/pkg/server/api/connect/v1/graphql/scim.graphql +++ b/pkg/server/api/connect/v1/graphql/scim.graphql @@ -17,7 +17,7 @@ type SCIMConfiguration implements Node { permission(action: String!): Boolean! @goField(forceResolver: true) - @session(required: PRESENT) + @authentication(required: PRESENT) } type SCIMBridge implements Node { @@ -33,7 +33,7 @@ type SCIMBridge implements Node { permission(action: String!): Boolean! @goField(forceResolver: true) - @session(required: PRESENT) + @authentication(required: PRESENT) } type Connector implements Node { @@ -44,7 +44,7 @@ type Connector implements Node { permission(action: String!): Boolean! @goField(forceResolver: true) - @session(required: PRESENT) + @authentication(required: PRESENT) } enum ConnectorProvider @@ -94,7 +94,7 @@ type SCIMEvent implements Node { permission(action: String!): Boolean! @goField(forceResolver: true) - @session(required: PRESENT) + @authentication(required: PRESENT) } enum SCIMEventOrderField @@ -130,16 +130,16 @@ type SCIMEventEdge { extend type Mutation { createSCIMConfiguration( input: CreateSCIMConfigurationInput! - ): CreateSCIMConfigurationPayload @session(required: PRESENT) + ): CreateSCIMConfigurationPayload @authentication(required: PRESENT) @sessionOnly deleteSCIMConfiguration( input: DeleteSCIMConfigurationInput! - ): DeleteSCIMConfigurationPayload @session(required: PRESENT) + ): DeleteSCIMConfigurationPayload @authentication(required: PRESENT) @sessionOnly regenerateSCIMToken( input: RegenerateSCIMTokenInput! - ): RegenerateSCIMTokenPayload @session(required: PRESENT) + ): RegenerateSCIMTokenPayload @authentication(required: PRESENT) @sessionOnly updateSCIMBridge( input: UpdateSCIMBridgeInput! - ): UpdateSCIMBridgePayload @session(required: PRESENT) + ): UpdateSCIMBridgePayload @authentication(required: PRESENT) } input CreateSCIMConfigurationInput { diff --git a/pkg/server/api/connect/v1/graphql/session.graphql b/pkg/server/api/connect/v1/graphql/session.graphql index 5b829a5b3..d04298e1d 100644 --- a/pkg/server/api/connect/v1/graphql/session.graphql +++ b/pkg/server/api/connect/v1/graphql/session.graphql @@ -5,29 +5,29 @@ enum ReauthenticationReason { } extend type Mutation { - signIn(input: SignInInput!): SignInPayload @session(required: OPTIONAL) - signUp(input: SignUpInput!): SignUpPayload @session(required: NONE) - signOut: SignOutPayload @session(required: PRESENT) + signIn(input: SignInInput!): SignInPayload @authentication(required: OPTIONAL) + signUp(input: SignUpInput!): SignUpPayload @authentication(required: NONE) + signOut: SignOutPayload @authentication(required: PRESENT) @sessionOnly activateAccount( input: ActivateAccountInput! - ): ActivateAccountPayload @session(required: OPTIONAL) + ): ActivateAccountPayload @authentication(required: OPTIONAL) forgotPassword(input: ForgotPasswordInput!): ForgotPasswordPayload - @session(required: NONE) + @authentication(required: NONE) resetPassword(input: ResetPasswordInput!): ResetPasswordPayload - @session(required: NONE) + @authentication(required: NONE) verifyEmail(input: VerifyEmailInput!): VerifyEmailPayload - @session(required: OPTIONAL) + @authentication(required: OPTIONAL) changePassword(input: ChangePasswordInput!): ChangePasswordPayload - @session(required: PRESENT) + @authentication(required: PRESENT) @sessionOnly changeEmail(input: ChangeEmailInput!): ChangeEmailPayload - @session(required: PRESENT) + @authentication(required: PRESENT) @sessionOnly assumeOrganizationSession( input: AssumeOrganizationSessionInput! - ): AssumeOrganizationSessionPayload @session(required: PRESENT) + ): AssumeOrganizationSessionPayload @authentication(required: PRESENT) @sessionOnly revokeSession(input: RevokeSessionInput!): RevokeSessionPayload! - @session(required: PRESENT) - revokeAllSessions: RevokeAllSessionsPayload @session(required: PRESENT) + @authentication(required: PRESENT) @sessionOnly + revokeAllSessions: RevokeAllSessionsPayload @authentication(required: PRESENT) @sessionOnly } enum SessionOrderField @@ -56,7 +56,7 @@ type Session implements Node { permission(action: String!): Boolean! @goField(forceResolver: true) - @session(required: PRESENT) + @authentication(required: PRESENT) } type SessionConnection diff --git a/pkg/server/api/connect/v1/graphql_handler.go b/pkg/server/api/connect/v1/graphql_handler.go index edd43be54..c45496b0f 100644 --- a/pkg/server/api/connect/v1/graphql_handler.go +++ b/pkg/server/api/connect/v1/graphql_handler.go @@ -25,6 +25,7 @@ import ( "go.probo.inc/probo/pkg/server/api/authz" "go.probo.inc/probo/pkg/server/api/connect/v1/schema" "go.probo.inc/probo/pkg/server/gqlutils" + "go.probo.inc/probo/pkg/server/gqlutils/directives/authentication" "go.probo.inc/probo/pkg/server/gqlutils/directives/session" ) @@ -39,7 +40,8 @@ func NewGraphQLHandler(svc *iam.Service, logger *log.Logger, baseURL *baseurl.Ba sessionCookie: authn.NewCookie(&cookieConfig), }, Directives: schema.DirectiveRoot{ - Session: session.Directive, + Authentication: authentication.Directive, + SessionOnly: session.Directive, }, } diff --git a/pkg/server/api/trust/v1/gqlgen.yaml b/pkg/server/api/trust/v1/gqlgen.yaml index ded75d24b..b76968590 100644 --- a/pkg/server/api/trust/v1/gqlgen.yaml +++ b/pkg/server/api/trust/v1/gqlgen.yaml @@ -1,5 +1,6 @@ schema: - "graphql/*.graphql" + - "../../../gqlutils/directives/authentication/schema.graphql" - "../../../gqlutils/directives/session/schema.graphql" exec: diff --git a/pkg/server/api/trust/v1/graphql/auth.graphql b/pkg/server/api/trust/v1/graphql/auth.graphql index ee56f8764..e4c0b694d 100644 --- a/pkg/server/api/trust/v1/graphql/auth.graphql +++ b/pkg/server/api/trust/v1/graphql/auth.graphql @@ -1,10 +1,10 @@ extend type Mutation { sendMagicLink(input: SendMagicLinkInput!): SendMagicLinkPayload - @session(required: OPTIONAL) + @authentication(required: OPTIONAL) verifyMagicLink(input: VerifyMagicLinkInput!): VerifyMagicLinkPayload - @session(required: OPTIONAL) + @authentication(required: OPTIONAL) updateFullName(input: UpdateFullNameInput!): UpdateFullNamePayload - @session(required: PRESENT) + @authentication(required: PRESENT) @sessionOnly } input SendMagicLinkInput { diff --git a/pkg/server/api/trust/v1/graphql/base.graphql b/pkg/server/api/trust/v1/graphql/base.graphql index 5b49bc8a2..4ad0c3c78 100644 --- a/pkg/server/api/trust/v1/graphql/base.graphql +++ b/pkg/server/api/trust/v1/graphql/base.graphql @@ -27,7 +27,7 @@ type Query { currentTrustCenter: TrustCenter oidcProviders: [OIDCProviderInfo!]! @goField(forceResolver: true) - @session(required: OPTIONAL) + @authentication(required: OPTIONAL) } type OIDCProviderInfo { diff --git a/pkg/server/api/trust/v1/graphql/mailing_list.graphql b/pkg/server/api/trust/v1/graphql/mailing_list.graphql index 8b38efe4a..061291f86 100644 --- a/pkg/server/api/trust/v1/graphql/mailing_list.graphql +++ b/pkg/server/api/trust/v1/graphql/mailing_list.graphql @@ -42,9 +42,9 @@ type MailingListSubscriber implements Node } extend type Mutation { - subscribeToMailingList: SubscribeToMailingListPayload! @session(required: PRESENT) + subscribeToMailingList: SubscribeToMailingListPayload! @authentication(required: PRESENT) @sessionOnly - unsubscribeFromMailingList: UnsubscribeFromMailingListPayload! @session(required: PRESENT) + unsubscribeFromMailingList: UnsubscribeFromMailingListPayload! @authentication(required: PRESENT) @sessionOnly } type SubscribeToMailingListPayload { diff --git a/pkg/server/api/trust/v1/graphql/nda.graphql b/pkg/server/api/trust/v1/graphql/nda.graphql index 0425fa62d..fd268b0e3 100644 --- a/pkg/server/api/trust/v1/graphql/nda.graphql +++ b/pkg/server/api/trust/v1/graphql/nda.graphql @@ -124,11 +124,11 @@ type ElectronicSignature implements Node { extend type Mutation { acceptElectronicSignature( input: AcceptElectronicSignatureInput! - ): AcceptElectronicSignaturePayload @session(required: PRESENT) + ): AcceptElectronicSignaturePayload @authentication(required: PRESENT) @sessionOnly recordSigningEvent( input: RecordSigningEventInput! - ): RecordSigningEventPayload @session(required: PRESENT) + ): RecordSigningEventPayload @authentication(required: PRESENT) @sessionOnly } input AcceptElectronicSignatureInput { diff --git a/pkg/server/api/trust/v1/graphql/trust_center.graphql b/pkg/server/api/trust/v1/graphql/trust_center.graphql index 10bbea93e..5a7c6110e 100644 --- a/pkg/server/api/trust/v1/graphql/trust_center.graphql +++ b/pkg/server/api/trust/v1/graphql/trust_center.graphql @@ -346,29 +346,29 @@ type DocumentAccess implements Node { } extend type Mutation { - requestAllAccesses: RequestAccessesPayload! @session(required: PRESENT) @nda + requestAllAccesses: RequestAccessesPayload! @authentication(required: PRESENT) @nda exportDocumentPDF(input: ExportDocumentPDFInput!): ExportDocumentPDFPayload! - @session(required: OPTIONAL) @nda + @authentication(required: OPTIONAL) @nda exportReportPDF(input: ExportReportPDFInput!): ExportReportPDFPayload! - @session(required: OPTIONAL) @nda + @authentication(required: OPTIONAL) @nda exportTrustCenterFile( input: ExportTrustCenterFileInput! - ): ExportTrustCenterFilePayload! @session(required: OPTIONAL) @nda + ): ExportTrustCenterFilePayload! @authentication(required: OPTIONAL) @nda requestDocumentAccess( input: RequestDocumentAccessInput! - ): RequestDocumentAccessPayload! @session(required: PRESENT) @nda + ): RequestDocumentAccessPayload! @authentication(required: PRESENT) @nda requestReportAccess( input: RequestReportAccessInput! - ): RequestReportAccessPayload! @session(required: PRESENT) @nda + ): RequestReportAccessPayload! @authentication(required: PRESENT) @nda requestTrustCenterFileAccess( input: RequestTrustCenterFileAccessInput! - ): RequestFileAccessPayload! @session(required: PRESENT) @nda + ): RequestFileAccessPayload! @authentication(required: PRESENT) @nda } type RequestDocumentAccessPayload { diff --git a/pkg/server/api/trust/v1/graphql_handler.go b/pkg/server/api/trust/v1/graphql_handler.go index 48a69b628..4325fde14 100644 --- a/pkg/server/api/trust/v1/graphql_handler.go +++ b/pkg/server/api/trust/v1/graphql_handler.go @@ -26,6 +26,7 @@ import ( "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/trust/v1/schema" "go.probo.inc/probo/pkg/server/gqlutils" + "go.probo.inc/probo/pkg/server/gqlutils/directives/authentication" "go.probo.inc/probo/pkg/server/gqlutils/directives/session" "go.probo.inc/probo/pkg/trust" ) @@ -42,8 +43,9 @@ func NewGraphQLHandler(iamSvc *iam.Service, trustSvc *trust.Service, esignSvc *e sessionCookie: authn.NewCookie(&cookieConfig), }, Directives: schema.DirectiveRoot{ - Nda: newNDADirective(logger, trustSvc, esignSvc), - Session: session.Directive, + Nda: newNDADirective(logger, trustSvc, esignSvc), + Authentication: authentication.Directive, + SessionOnly: session.Directive, }, } diff --git a/pkg/server/gqlutils/directives/authentication/authentication.go b/pkg/server/gqlutils/directives/authentication/authentication.go new file mode 100644 index 000000000..82fc786d0 --- /dev/null +++ b/pkg/server/gqlutils/directives/authentication/authentication.go @@ -0,0 +1,112 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package authentication + +import ( + "bytes" + "context" + "fmt" + "io" + "strconv" + + "github.com/99designs/gqlgen/graphql" + "go.probo.inc/probo/pkg/server/api/authn" + "go.probo.inc/probo/pkg/server/gqlutils" +) + +type AuthenticationRequirement string + +const ( + AuthenticationRequirementPresent AuthenticationRequirement = "PRESENT" + AuthenticationRequirementNone AuthenticationRequirement = "NONE" + AuthenticationRequirementOptional AuthenticationRequirement = "OPTIONAL" +) + +var AllAuthenticationRequirement = []AuthenticationRequirement{ + AuthenticationRequirementPresent, + AuthenticationRequirementNone, + AuthenticationRequirementOptional, +} + +func (e AuthenticationRequirement) IsValid() bool { + switch e { + case AuthenticationRequirementPresent, AuthenticationRequirementNone, AuthenticationRequirementOptional: + return true + } + + return false +} + +func (e AuthenticationRequirement) String() string { + return string(e) +} + +func (e *AuthenticationRequirement) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = AuthenticationRequirement(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid AuthenticationRequirement", str) + } + + return nil +} + +func (e AuthenticationRequirement) MarshalGQL(w io.Writer) { + _, _ = fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *AuthenticationRequirement) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + + return e.UnmarshalGQL(s) +} + +func (e AuthenticationRequirement) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + + return buf.Bytes(), nil +} + +func Directive(ctx context.Context, obj any, next graphql.Resolver, required AuthenticationRequirement) (any, error) { + identity := authn.IdentityFromContext(ctx) + + switch required { + case AuthenticationRequirementOptional: + case AuthenticationRequirementPresent: + if identity == nil { + return nil, gqlutils.Unauthenticatedf( + ctx, + "authentication is required to access this resource", + ) + } + case AuthenticationRequirementNone: + if identity != nil { + return nil, gqlutils.AlreadyAuthenticatedf( + ctx, + "authentication not allowed for this resource/action", + ) + } + } + + return next(ctx) +} diff --git a/pkg/server/gqlutils/directives/authentication/schema.graphql b/pkg/server/gqlutils/directives/authentication/schema.graphql new file mode 100644 index 000000000..febd1dce5 --- /dev/null +++ b/pkg/server/gqlutils/directives/authentication/schema.graphql @@ -0,0 +1,39 @@ +# Authentication directive for GraphQL APIs +# Include this schema in your gqlgen configuration to enable identity-based +# access control (session OR API key). +# +# Usage in your schema.graphql: +# type Query { +# viewer: User @authentication(required: PRESENT) +# publicData: Data @authentication(required: OPTIONAL) +# signup(input: SignUpInput!): SignUpPayload @authentication(required: NONE) +# } + +directive @authentication(required: AuthenticationRequirement!) on FIELD_DEFINITION + +enum AuthenticationRequirement + @goModel( + model: "go.probo.inc/probo/pkg/server/gqlutils/directives/authentication.AuthenticationRequirement" + ) { + """ + Requires an authenticated identity (session or API key). + """ + PRESENT + @goEnum( + value: "go.probo.inc/probo/pkg/server/gqlutils/directives/authentication.AuthenticationRequirementPresent" + ) + """ + Forbids authenticated access (e.g., for login/signup endpoints). + """ + NONE + @goEnum( + value: "go.probo.inc/probo/pkg/server/gqlutils/directives/authentication.AuthenticationRequirementNone" + ) + """ + Allows both authenticated and unauthenticated access. + """ + OPTIONAL + @goEnum( + value: "go.probo.inc/probo/pkg/server/gqlutils/directives/authentication.AuthenticationRequirementOptional" + ) +} diff --git a/pkg/server/gqlutils/directives/session/schema.graphql b/pkg/server/gqlutils/directives/session/schema.graphql index b61fff24c..4d5abb248 100644 --- a/pkg/server/gqlutils/directives/session/schema.graphql +++ b/pkg/server/gqlutils/directives/session/schema.graphql @@ -1,38 +1,18 @@ -# Session directive for GraphQL APIs -# Include this schema in your gqlgen configuration to enable session-based access control. +# sessionOnly directive for GraphQL APIs +# Include this schema in your gqlgen configuration to restrict a field to +# session authentication. +# +# It is meant to be combined with @authentication(required: PRESENT): +# @authentication enforces that the caller is authenticated, while @sessionOnly +# additionally rejects API key authentication (which carries an identity but no +# session). Use it for sensitive identity self-management (e.g. managing +# personal API keys). # # Usage in your schema.graphql: -# type Query { -# viewer: User @session(required: PRESENT) -# publicData: Data @session(required: OPTIONAL) -# signup(input: SignUpInput!): SignUpPayload @session(required: NONE) +# type Mutation { +# createPersonalAPIKey(input: CreatePersonalAPIKeyInput!): CreatePersonalAPIKeyPayload +# @authentication(required: PRESENT) +# @sessionOnly # } -directive @session(required: SessionRequirement!) on FIELD_DEFINITION - -enum SessionRequirement - @goModel( - model: "go.probo.inc/probo/pkg/server/gqlutils/directives/session.SessionRequirement" - ) { - """ - Requires an authenticated session or API key. - """ - PRESENT - @goEnum( - value: "go.probo.inc/probo/pkg/server/gqlutils/directives/session.SessionRequirementPresent" - ) - """ - Forbids authenticated access (e.g., for login/signup endpoints). - """ - NONE - @goEnum( - value: "go.probo.inc/probo/pkg/server/gqlutils/directives/session.SessionRequirementNone" - ) - """ - Allows both authenticated and unauthenticated access. - """ - OPTIONAL - @goEnum( - value: "go.probo.inc/probo/pkg/server/gqlutils/directives/session.SessionRequirementOptional" - ) -} +directive @sessionOnly on FIELD_DEFINITION diff --git a/pkg/server/gqlutils/directives/session/session.go b/pkg/server/gqlutils/directives/session/session.go index 1579fd0c9..c8762d7a2 100644 --- a/pkg/server/gqlutils/directives/session/session.go +++ b/pkg/server/gqlutils/directives/session/session.go @@ -15,97 +15,23 @@ package session import ( - "bytes" "context" - "fmt" - "io" - "strconv" "github.com/99designs/gqlgen/graphql" "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/gqlutils" ) -type SessionRequirement string - -const ( - SessionRequirementPresent SessionRequirement = "PRESENT" - SessionRequirementNone SessionRequirement = "NONE" - SessionRequirementOptional SessionRequirement = "OPTIONAL" -) - -var AllSessionRequirement = []SessionRequirement{ - SessionRequirementPresent, - SessionRequirementNone, - SessionRequirementOptional, -} - -func (e SessionRequirement) IsValid() bool { - switch e { - case SessionRequirementPresent, SessionRequirementNone, SessionRequirementOptional: - return true - } - - return false -} - -func (e SessionRequirement) String() string { - return string(e) -} - -func (e *SessionRequirement) UnmarshalGQL(v any) error { - str, ok := v.(string) - if !ok { - return fmt.Errorf("enums must be strings") - } - - *e = SessionRequirement(str) - if !e.IsValid() { - return fmt.Errorf("%s is not a valid SessionRequirement", str) - } - - return nil -} - -func (e SessionRequirement) MarshalGQL(w io.Writer) { - _, _ = fmt.Fprint(w, strconv.Quote(e.String())) -} - -func (e *SessionRequirement) UnmarshalJSON(b []byte) error { - s, err := strconv.Unquote(string(b)) - if err != nil { - return err - } - - return e.UnmarshalGQL(s) -} - -func (e SessionRequirement) MarshalJSON() ([]byte, error) { - var buf bytes.Buffer - e.MarshalGQL(&buf) - - return buf.Bytes(), nil -} - -func Directive(ctx context.Context, obj any, next graphql.Resolver, required SessionRequirement) (any, error) { - identity := authn.IdentityFromContext(ctx) - - switch required { - case SessionRequirementOptional: - case SessionRequirementPresent: - if identity == nil { - return nil, gqlutils.Unauthenticatedf( - ctx, - "authentication is required to access this resource", - ) - } - case SessionRequirementNone: - if identity != nil { - return nil, gqlutils.AlreadyAuthenticatedf( - ctx, - "authentication not allowed for this resource/action", - ) - } +// Directive enforces that the request carries a real session. It is meant to +// be combined with @authentication(required: PRESENT): @authentication handles +// the "must be authenticated" check, while @sessionOnly additionally rejects +// API key authentication, which also carries an identity but no session. +func Directive(ctx context.Context, obj any, next graphql.Resolver) (any, error) { + if authn.SessionFromContext(ctx) == nil { + return nil, gqlutils.Forbiddenf( + ctx, + "this resource can only be accessed with session authentication, not API key authentication", + ) } return next(ctx)