diff --git a/.gitignore b/.gitignore index be24e66..6e4d3d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .env .idea +config.json config.local.json delme.* *.prod.yml diff --git a/LICENSE b/LICENSE index 05f2ccb..16ab856 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2022 Micah Parks + Copyright 2024 Micah Parks Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index e598e0b..e1b4bf1 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,59 @@ # magiclinksdev -You can find the documentation for this project on the [docs site](https://docs.magiclinks.dev). This site contains -resources for implementing a client and self-hosting the project. - -You can find the SaaS landing page at [https://magiclinks.dev](https://magiclinks.dev). Use of the SaaS platform is not -required, but it's very inexpensive and may be cheaper than deploying yourself. - -# Getting started - -The **magiclinksdev** project is an authentication service that uses magic links to authenticate users. A typical use -case would involve sending a magic link to a user via email. After the user clicks the link, a new authenticated session -is created for that user. Sometimes **magiclinksdev** is abbreviated as "mld". - -## About - -This project is a magic link authentication service. It serves use cases like: +The **magiclinksdev** project is an authentication service for magic link and One-Time Password (OTP) use cases. There +is built-in email support through Amazon SES and SendGrid. +Use cases include: * Sign up * Log in * Password resets * Email verification * And more authentication use cases -It can be used to supplement password authentication or replace it entirely. +This project can be used to supplement password authentication or replace it entirely. + +If your project has an alternate secure means of communication, you can use generate magic links and OTPs without +sending emails. An example would be mobile push notifications. -A typical use case involves sending a magic link to a user via email. After the user clicks the link, a new -authenticated session is created for that user. +## Getting started + +To get started implementing a client application that uses **magiclinksdev** for authentication, the recommended path +is: +1. Do the [quickstart](https://docs.magiclinks.dev/self-host-quickstart) +2. Find a [pre-built SDK](https://docs.magiclinks.dev/client-sdk) or [generate one from the formatted API specification](https://docs.magiclinks.dev/client-api-specification#generate-code) +3. Choose the [magic link](https://docs.magiclinks.dev/client-magic-link-workflow) or [OTP](https://docs.magiclinks.dev/client-otp-workflow) workflow +4. Review the [implementation tips](https://docs.magiclinks.dev/client-implementation-tips) for recommendations and best practices ## Screenshots -The built-in email template is populated on a per-request basis. It adapts to the device's theme automatically. This -template was built using [maizzle](https://maizzle.com/). +The built-in email templates are friendly to mobile and desktop screens. They also adapt to light/dark mode +automatically. The templates are built using [maizzle](https://maizzle.com/). - - + + + + + + -## Suggested Email Workflow - - - -## Implementing a client application +## Suggested Magic Link Workflow + -Client applications are programs that use the **magiclinksdev** project to authenticate their users. Check out the -[**SDKs**](https://docs.magiclinks.dev/sdks) page to get started with an existing SDK. If you can't find an SDK for your -language, you can use the [**Specification**](https://docs.magiclinks.dev/specification) to implement your own client by -hand or code generation. To learn more about the client workflow, check out the -[**Workflow**](https://docs.magiclinks.dev/workflow) page. +## Suggested OTP Workflow + ## Self-hosting the service -The **magiclinksdev** project can be self-hosted. Check out the [**Quickstart**](https://docs.magiclinks.dev/quickstart) -page to get started in minutes. For reference on configuring your self-hosted instance, check out the -[**Configuration**](https://docs.magiclinks.dev/configuration). +The **magiclinksdev** project is open-source and can be self-hosted. Check out the [**Quickstart**](https://docs.magiclinks.dev/self-host-quickstart) page +to get started in minutes. For reference on configuring your self-hosted instance, check out the +[**Configuration**](https://docs.magiclinks.dev/self-host-configuration). ## Source code and license The **magiclinksdev** project is [open source on GitHub](https://github.com/MicahParks/magiclinksdev) and licensed -under [Apache License 2.0](https://github.com/MicahParks/magiclinksdev/blob/master/LICENSE). +under [**Apache License 2.0**](https://github.com/MicahParks/magiclinksdev/blob/master/LICENSE). ## Optional SaaS platform diff --git a/buildDocker.sh b/buildDocker.sh index 18c498f..95a4769 100644 --- a/buildDocker.sh +++ b/buildDocker.sh @@ -7,16 +7,16 @@ if [ "$1" = "push" ]; then then exit 1 fi - docker build --pull --push --file nop.Dockerfile --tag micahparks/magiclinksdevmulti . + docker build --pull --push --file multi.Dockerfile --tag micahparks/magiclinksdevmulti . docker build --pull --push --file nop.Dockerfile --tag micahparks/magiclinksdevnop . docker build --pull --push --file ses.Dockerfile --tag micahparks/magiclinksdevses . docker build --pull --push --file sendgrid.Dockerfile --tag micahparks/magiclinksdevsendgrid . - docker build --pull --push --file nop.Dockerfile --tag "micahparks/magiclinksdevmulti:$TAG" . + docker build --pull --push --file multi.Dockerfile --tag "micahparks/magiclinksdevmulti:$TAG" . docker build --pull --push --file nop.Dockerfile --tag "micahparks/magiclinksdevnop:$TAG" . docker build --pull --push --file ses.Dockerfile --tag "micahparks/magiclinksdevses:$TAG" . docker build --pull --push --file sendgrid.Dockerfile --tag "micahparks/magiclinksdevsendgrid:$TAG" . else - docker build --pull --file nop.Dockerfile --tag micahparks/magiclinksdevmulti . + docker build --pull --file multi.Dockerfile --tag micahparks/magiclinksdevmulti . docker build --pull --file nop.Dockerfile --tag micahparks/magiclinksdevnop . docker build --pull --file ses.Dockerfile --tag micahparks/magiclinksdevses . docker build --pull --file sendgrid.Dockerfile --tag micahparks/magiclinksdevsendgrid . diff --git a/client/client.go b/client/client.go index ec84d23..dc76e6c 100644 --- a/client/client.go +++ b/client/client.go @@ -22,8 +22,8 @@ import ( ) const ( - // SaaSBaseURL is the base URL for the SaaS offering. The SaaS offering is optional and the magiclinksdev project - // can be self-hosted. + // SaaSBaseURL is the base URL for the SaaS platform. The SaaS platform is optional and the magiclinksdev project + // is open-source and can be self-hosted. SaaSBaseURL = "https://magiclinks.dev" // SaaSIss is the iss claim for JWTs in the SaaS offering. SaaSIss = SaaSBaseURL @@ -43,7 +43,7 @@ type Options struct { HTTP *http.Client } -// Client is a client for the magiclinksdev project. +// Client is the official Golang API client for the magiclinksdev project. type Client struct { apiKey uuid.UUID aud uuid.UUID @@ -54,9 +54,9 @@ type Client struct { } // New creates a new magiclinksdev client. The apiKey and aud are tied to the service account being used. The baseURL is -// the HTTP(S) location of the magiclinksdev deployment. Only use HTTPS in production. For the SaaS offering, use the +// the HTTP(S) location of the magiclinksdev deployment. Only use HTTPS in production. For the SaaS platform, use the // SaaSBaseURL constant. The iss is the issuer of the JWTs, which is in the configuration of the magiclinksdev -// deployment. For the SaaS offering, use the SaaSIss constant. Providing an empty string for the iss will disable +// deployment. For the SaaS platform, use the SaaSIss constant. Providing an empty string for the iss will disable // issuer validation. func New(apiKey, aud uuid.UUID, baseURL, iss string, options Options) (Client, error) { u, err := url.Parse(baseURL) @@ -96,8 +96,8 @@ func New(apiKey, aud uuid.UUID, baseURL, iss string, options Options) (Client, e } // LocalJWTValidate validates a JWT locally. If the claims argument is not nil, its value will be passed directly to -// jwt.ParseWithClaims. The claims should be unmarshalled into the provided non-nil pointer after the function call. See -// the documentation for jwt.ParseWithClaims for more information. Registered JWT claims will be validated regardless if +// jwt.ParseWithClaims. The claims should be unmarshalled into the claims argument if it is a non-nil pointer. See the +// documentation for jwt.ParseWithClaims for more information. Registered JWT claims will be validated regardless if // claims are specified or not. func (c Client) LocalJWTValidate(token string, claims jwt.Claims) (*jwt.Token, error) { if c.keyf == nil { @@ -136,15 +136,6 @@ func (c Client) LocalJWTValidate(token string, claims jwt.Claims) (*jwt.Token, e return t, nil } -// EmailLinkCreate calls the /email-link/create endpoint and returns the appropriate response. -func (c Client) EmailLinkCreate(ctx context.Context, req model.EmailLinkCreateRequest) (model.EmailLinkCreateResponse, model.Error, error) { - resp, errResp, err := request[model.EmailLinkCreateRequest, model.EmailLinkCreateResponse](ctx, c, http.StatusCreated, network.PathEmailLinkCreate, req) - if err != nil { - return model.EmailLinkCreateResponse{}, errResp, fmt.Errorf("failed to create email link: %w", err) - } - return resp, errResp, nil -} - // JWTCreate calls the /jwt/create endpoint and returns the appropriate response. func (c Client) JWTCreate(ctx context.Context, req model.JWTCreateRequest) (model.JWTCreateResponse, model.Error, error) { resp, errResp, err := request[model.JWTCreateRequest, model.JWTCreateResponse](ctx, c, http.StatusCreated, network.PathJWTCreate, req) @@ -165,16 +156,52 @@ func (c Client) JWTValidate(ctx context.Context, req model.JWTValidateRequest) ( return resp, errResp, nil } -// LinkCreate calls the /link/create endpoint and returns the appropriate response. -func (c Client) LinkCreate(ctx context.Context, req model.LinkCreateRequest) (model.LinkCreateResponse, model.Error, error) { - resp, errResp, err := request[model.LinkCreateRequest, model.LinkCreateResponse](ctx, c, http.StatusCreated, network.PathLinkCreate, req) +// MagicLinkCreate calls the /magic-link/create endpoint and returns the appropriate response. +func (c Client) MagicLinkCreate(ctx context.Context, req model.MagicLinkCreateRequest) (model.MagicLinkCreateResponse, model.Error, error) { + resp, errResp, err := request[model.MagicLinkCreateRequest, model.MagicLinkCreateResponse](ctx, c, http.StatusCreated, network.PathMagicLinkCreate, req) + if err != nil { + return model.MagicLinkCreateResponse{}, errResp, fmt.Errorf("failed to create link: %w", err) + } + return resp, errResp, nil +} + +// MagicLinkEmailCreate calls the /magic-link-email/create endpoint and returns the appropriate response. +func (c Client) MagicLinkEmailCreate(ctx context.Context, req model.MagicLinkEmailCreateRequest) (model.MagicLinkEmailCreateResponse, model.Error, error) { + resp, errResp, err := request[model.MagicLinkEmailCreateRequest, model.MagicLinkEmailCreateResponse](ctx, c, http.StatusCreated, network.PathMagicLinkEmailCreate, req) + if err != nil { + return model.MagicLinkEmailCreateResponse{}, errResp, fmt.Errorf("failed to create email link: %w", err) + } + return resp, errResp, nil +} + +// OTPCreate calls the /otp/create endpoint and returns the appropriate response. +func (c Client) OTPCreate(ctx context.Context, req model.OTPCreateRequest) (model.OTPCreateResponse, model.Error, error) { + resp, errResp, err := request[model.OTPCreateRequest, model.OTPCreateResponse](ctx, c, http.StatusCreated, network.PathOTPCreate, req) + if err != nil { + return model.OTPCreateResponse{}, errResp, fmt.Errorf("failed to create OTP: %w", err) + } + return resp, errResp, nil +} + +// OTPValidate calls the /otp/validate endpoint and returns the appropriate response. +func (c Client) OTPValidate(ctx context.Context, req model.OTPValidateRequest) (model.OTPValidateResponse, model.Error, error) { + resp, errResp, err := request[model.OTPValidateRequest, model.OTPValidateResponse](ctx, c, http.StatusOK, network.PathOTPValidate, req) + if err != nil { + return model.OTPValidateResponse{}, errResp, fmt.Errorf("failed to validate OTP: %w", err) + } + return resp, errResp, nil +} + +// OTPEmailCreate calls the /otp-email/create endpoint and returns the appropriate response. +func (c Client) OTPEmailCreate(ctx context.Context, req model.OTPEmailCreateRequest) (model.OTPEmailCreateResponse, model.Error, error) { + resp, errResp, err := request[model.OTPEmailCreateRequest, model.OTPEmailCreateResponse](ctx, c, http.StatusCreated, network.PathOTPEmailCreate, req) if err != nil { - return model.LinkCreateResponse{}, errResp, fmt.Errorf("failed to create link: %w", err) + return model.OTPEmailCreateResponse{}, errResp, fmt.Errorf("failed to create email OTP: %w", err) } return resp, errResp, nil } -// Ready calls the /ready endpoint. An error is returned if the service is not ready. +// Ready calls the /ready endpoint. An error is returned if the service is not ready to accept requests. func (c Client) Ready(ctx context.Context) error { u, err := c.baseURL.Parse(network.PathReady) if err != nil { diff --git a/client/client_test.go b/client/client_test.go index cea7c0e..4bfdf07 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -39,13 +39,13 @@ var ( _, attackerKey, _ = ed25519.GenerateKey(nil) redirectPath, _ = url.Parse(mld.DefaultRelativePathRedirect) - jwtCreateArgs = model.JWTCreateArgs{ - JWTClaims: mldtest.TClaims, - JWTLifespanSeconds: 5, + jwtCreateParams = model.JWTCreateParams{ + Claims: mldtest.TClaims, + LifespanSeconds: 5, } - linkArgs = model.LinkCreateArgs{ - JWTCreateArgs: jwtCreateArgs, - LinkLifespan: 5, + linkParams = model.MagicLinkCreateParams{ + JWTCreateParams: jwtCreateParams, + LifespanSeconds: 5, RedirectQueryKey: magiclink.DefaultRedirectQueryKey, RedirectURL: "http://example.com", } @@ -102,8 +102,8 @@ func TestEmailLinkCreate(t *testing.T) { ctx := createCtx(t) c := newClient(ctx, t) - req := model.EmailLinkCreateRequest{ - EmailArgs: model.EmailLinkCreateArgs{ + req := model.MagicLinkEmailCreateRequest{ + MagicLinkEmailCreateParams: model.MagicLinkEmailCreateParams{ ButtonText: "Test button text", Greeting: "Test greeting", LogoClickURL: mldtest.LogoClickURL, @@ -115,9 +115,9 @@ func TestEmailLinkCreate(t *testing.T) { ToEmail: "customer@example.com", ToName: "Test name", }, - LinkArgs: linkArgs, + MagicLinkCreateParams: linkParams, } - resp, mldErr, err := c.EmailLinkCreate(ctx, req) + resp, mldErr, err := c.MagicLinkEmailCreate(ctx, req) if err != nil { t.Fatalf("Failed to create email link: %v.", err) } @@ -126,7 +126,7 @@ func TestEmailLinkCreate(t *testing.T) { } validateMetadata(t, resp.RequestMetadata) - validateLinkResults(t, resp.EmailLinkCreateResults.LinkCreateResults) + validateLinkResults(t, resp.MagicLinkEmailCreateResults.MagicLinkCreateResults) } func TestJWTCreate(t *testing.T) { @@ -179,7 +179,7 @@ func TestJWTValidate(t *testing.T) { raw := jwtCreateHelper(ctx, t, c) req := model.JWTValidateRequest{ - JWTValidateArgs: model.JWTValidateArgs{ + JWTValidateParams: model.JWTValidateParams{ JWT: raw, }, } @@ -217,7 +217,7 @@ func TestJWTValidateForged(t *testing.T) { } req := model.JWTValidateRequest{ - JWTValidateArgs: model.JWTValidateArgs{ + JWTValidateParams: model.JWTValidateParams{ JWT: raw, }, } @@ -237,10 +237,10 @@ func TestLinkCreate(t *testing.T) { ctx := createCtx(t) c := newClient(ctx, t) - req := model.LinkCreateRequest{ - LinkArgs: linkArgs, + req := model.MagicLinkCreateRequest{ + MagicLinkCreateParams: linkParams, } - resp, mldErr, err := c.LinkCreate(ctx, req) + resp, mldErr, err := c.MagicLinkCreate(ctx, req) if err != nil { t.Fatalf("Failed to create magic link: %v.", err) } @@ -249,7 +249,7 @@ func TestLinkCreate(t *testing.T) { } validateMetadata(t, resp.RequestMetadata) - validateLinkResults(t, resp.LinkCreateResults) + validateLinkResults(t, resp.MagicLinkCreateResults) } func TestServiceAccountCreate(t *testing.T) { @@ -257,7 +257,7 @@ func TestServiceAccountCreate(t *testing.T) { c := newClient(ctx, t) req := model.ServiceAccountCreateRequest{ - CreateServiceAccountArgs: model.ServiceAccountCreateArgs{}, + ServiceAccountCreateParams: model.ServiceAccountCreateParams{}, } resp, mldErr, err := c.ServiceAccountCreate(ctx, req) if err != nil { @@ -269,16 +269,16 @@ func TestServiceAccountCreate(t *testing.T) { validateMetadata(t, resp.RequestMetadata) - if resp.CreateServiceAccountResults.ServiceAccount.Admin { + if resp.ServiceAccountCreateResults.ServiceAccount.Admin { t.Fatalf("Created service account should not be an admin.") } - if resp.CreateServiceAccountResults.ServiceAccount.UUID == uuid.Nil { + if resp.ServiceAccountCreateResults.ServiceAccount.UUID == uuid.Nil { t.Fatalf("Created service account should have non-nil UUID.") } - if resp.CreateServiceAccountResults.ServiceAccount.Aud == uuid.Nil { + if resp.ServiceAccountCreateResults.ServiceAccount.Aud == uuid.Nil { t.Fatalf("Created service account should have non-nil audience UUID.") } - if resp.CreateServiceAccountResults.ServiceAccount.APIKey == uuid.Nil { + if resp.ServiceAccountCreateResults.ServiceAccount.APIKey == uuid.Nil { t.Fatalf("Created service account should have non-nil API key.") } } @@ -291,7 +291,7 @@ func createCtx(t *testing.T) context.Context { func jwtCreateHelper(ctx context.Context, t *testing.T, c Client) string { req := model.JWTCreateRequest{ - JWTCreateArgs: jwtCreateArgs, + JWTCreateParams: jwtCreateParams, } resp, mldErr, err := c.JWTCreate(ctx, req) if err != nil { @@ -324,12 +324,12 @@ func jwtCreateHelper(ctx context.Context, t *testing.T, c Client) string { func newClient(ctx context.Context, t *testing.T) Client { conf := config.Config{ - AdminConfig: []model.AdminCreateArgs{ + AdminCreateParams: []model.AdminCreateParams{ { - APIKey: mldtest.APIKey, - Aud: mldtest.Aud, - UUID: mldtest.SAUUID, - ServiceAccountCreateArgs: model.ServiceAccountCreateArgs{}, + APIKey: mldtest.APIKey, + Aud: mldtest.Aud, + UUID: mldtest.SAUUID, + ServiceAccountCreateParams: model.ServiceAccountCreateParams{}, }, }, BaseURL: jt.New(baseURL), @@ -407,7 +407,7 @@ func newClient(ctx context.Context, t *testing.T) Client { return c } -func validateLinkResults(t *testing.T, results model.LinkCreateResults) { +func validateLinkResults(t *testing.T, results model.MagicLinkCreateResults) { ml, err := url.Parse(results.MagicLink) if err != nil { t.Fatalf("Failed to parse magic link: %v.", err) diff --git a/cmd/stress/go.mod b/cmd/stress/go.mod index 4f90781..48cc877 100644 --- a/cmd/stress/go.mod +++ b/cmd/stress/go.mod @@ -1,27 +1,31 @@ module github.com/MicahParks/magiclinksdev/cmd/stress -go 1.21.1 +go 1.22.0 + +toolchain go1.23.2 require ( - github.com/MicahParks/magiclinksdev v0.4.3 - golang.org/x/sync v0.3.0 + github.com/MicahParks/magiclinksdev v0.5.1 + golang.org/x/sync v0.8.0 ) require ( - github.com/MicahParks/jsontype v0.5.0 // indirect - github.com/MicahParks/jwkset v0.3.1 // indirect - github.com/MicahParks/keyfunc v1.9.0 // indirect + github.com/MicahParks/jsontype v0.6.1 // indirect + github.com/MicahParks/jwkset v0.5.20 // indirect + github.com/MicahParks/keyfunc/v3 v3.3.5 // indirect github.com/MicahParks/recaptcha v0.0.5 // indirect - github.com/golang-jwt/jwt/v4 v4.5.0 // indirect - github.com/google/uuid v1.3.1 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/pgx/v5 v5.4.3 // indirect - github.com/tidwall/gjson v1.16.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.1 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect - golang.org/x/crypto v0.13.0 // indirect - golang.org/x/text v0.13.0 // indirect - golang.org/x/time v0.3.0 // indirect + golang.org/x/crypto v0.28.0 // indirect + golang.org/x/mod v0.21.0 // indirect + golang.org/x/text v0.19.0 // indirect + golang.org/x/time v0.7.0 // indirect ) diff --git a/cmd/stress/go.sum b/cmd/stress/go.sum index ec3ddd3..5a63d40 100644 --- a/cmd/stress/go.sum +++ b/cmd/stress/go.sum @@ -1,47 +1,46 @@ -github.com/MicahParks/jsontype v0.5.0 h1:O/7LAAbWEe3sPNvwaLINy6eEvgINQlJRNEShyUV5Jrc= -github.com/MicahParks/jsontype v0.5.0/go.mod h1:PVeg4g8eHt4QDlhe56X1sWzRuHiVlCg4m0vgkpEso/Y= -github.com/MicahParks/jwkset v0.3.1 h1:DIVazR/elD8CLWPblrVo610TzovIDYMcvlM4X0UT0vQ= -github.com/MicahParks/jwkset v0.3.1/go.mod h1:Ob0sxSgMmQZFg4GO59PVBnfm+jtdQ1MJbfZDU90tEwM= -github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= -github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= -github.com/MicahParks/magiclinksdev v0.4.3 h1:kSnPlAZwW8uzdiE/aC2AcPNs8zcFAklHlzLVlMIdSIo= -github.com/MicahParks/magiclinksdev v0.4.3/go.mod h1:avz0c4ftuAk6GWtOkBe/yUgAb+MZmUuTF3xPpsC/XJQ= +github.com/MicahParks/jsontype v0.6.1 h1:yFiDEOgSCDT+Es8k17PYZkvpqbZJ9GxJH2ioeVGvgt0= +github.com/MicahParks/jsontype v0.6.1/go.mod h1:PVeg4g8eHt4QDlhe56X1sWzRuHiVlCg4m0vgkpEso/Y= +github.com/MicahParks/jwkset v0.5.20 h1:gTIKx9AofTqQJ0srd8AL7ty9NeadP5WUXSPOZadTpOI= +github.com/MicahParks/jwkset v0.5.20/go.mod h1:q8ptTGn/Z9c4MwbcfeCDssADeVQb3Pk7PnVxrvi+2QY= +github.com/MicahParks/keyfunc/v3 v3.3.5 h1:7ceAJLUAldnoueHDNzF8Bx06oVcQ5CfJnYwNt1U3YYo= +github.com/MicahParks/keyfunc/v3 v3.3.5/go.mod h1:SdCCyMJn/bYqWDvARspC6nCT8Sk74MjuAY22C7dCST8= +github.com/MicahParks/magiclinksdev v0.5.1 h1:EB6h0YP7Qd31ycnr6yZqDjag3eUXKWjGQgkb/QMVOZ0= +github.com/MicahParks/magiclinksdev v0.5.1/go.mod h1:jwoiNsLu1qMyLEj4hSLD3mMwGPMXmMoyBMTwfhsAV8c= github.com/MicahParks/recaptcha v0.0.5 h1:RvKq7E1BZJtz5ubSkBun20jXxIsMWt2oZ0ppTJOzX1A= github.com/MicahParks/recaptcha v0.0.5/go.mod h1:aFv3iZDDs6Pbi6tRpUm8gofaTUnDxOQ27x5KsK0CZwE= -github.com/aws/aws-sdk-go v1.45.6 h1:Y2isQQBZsnO15dzUQo9YQRThtHgrV200XCH05BRHVJI= -github.com/aws/aws-sdk-go v1.45.6/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= +github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= +github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= -github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= -github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= -github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= -github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= -github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= -github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= +github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0= github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= -github.com/sendgrid/sendgrid-go v3.13.0+incompatible h1:HZrzc06/QfBGesY9o3n1lvBrRONA+57rbDRKet7plos= -github.com/sendgrid/sendgrid-go v3.13.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= +github.com/sendgrid/sendgrid-go v3.16.0+incompatible h1:i8eE6IMkiCy7vusSdacHHSBUpXyTcTXy/Rl9N9aZ/Qw= +github.com/sendgrid/sendgrid-go v3.16.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.16.0 h1:SyXa+dsSPpUlcwEDuKuEBJEz5vzTvOea+9rjyYodQFg= -github.com/tidwall/gjson v1.16.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= @@ -49,16 +48,16 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= -golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= +golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/cmd/stress/go.work b/cmd/stress/go.work new file mode 100644 index 0000000..5fb1412 --- /dev/null +++ b/cmd/stress/go.work @@ -0,0 +1,6 @@ +go 1.23.2 + +use ( + . + ../.. +) diff --git a/cmd/stress/go.work.sum b/cmd/stress/go.work.sum new file mode 100644 index 0000000..3ea587c --- /dev/null +++ b/cmd/stress/go.work.sum @@ -0,0 +1,56 @@ +github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2 h1:IRJeR9r1pYWsHKTRe/IInb7lYvbBVIqOgsX/u0mbOWY= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= +golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= diff --git a/cmd/stress/link.example.json b/cmd/stress/link.example.json index 1395e88..1284f2c 100644 --- a/cmd/stress/link.example.json +++ b/cmd/stress/link.example.json @@ -1,13 +1,13 @@ { - "linkArgs": { - "jwtCreateArgs": { - "jwtClaims": { + "magicLinkCreateParams": { + "jwtCreateParams": { + "claims": { "foo": "bar" }, - "jwtLifespanSeconds": 0 + "lifespanSeconds": 0 }, - "linkLifespan": 0, + "lifespanSeconds": 0, "redirectQueryKey": "", - "redirectUrl": "https://jwtdebug.micahparks.com" + "redirectURL": "https://jwtdebug.micahparks.com" } } diff --git a/cmd/stress/main.go b/cmd/stress/main.go index fe902b5..308a028 100644 --- a/cmd/stress/main.go +++ b/cmd/stress/main.go @@ -11,15 +11,16 @@ import ( "strings" "time" - "github.com/MicahParks/magiclinksdev/mldtest" "golang.org/x/sync/errgroup" + "github.com/MicahParks/magiclinksdev/mldtest" + mld "github.com/MicahParks/magiclinksdev" "github.com/MicahParks/magiclinksdev/network" "github.com/MicahParks/magiclinksdev/network/middleware" ) -//go:embed link.prod.json +//go:embed link.example.json var linkJSON []byte func main() { @@ -35,7 +36,7 @@ func main() { ) exit(ctx, l, time.Now(), 1) } - u, err = u.Parse("/api/v1/" + network.PathLinkCreate) + u, err = u.Parse("/api/v2/" + network.PathMagicLinkCreate) if err != nil { l.ErrorContext(ctx, "Failed to parse URL.", mld.LogErr, err, diff --git a/compose.yaml b/compose.yaml index 2ca0caf..f14f6cd 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,4 +1,3 @@ -version: "3.8" services: magiclinksdev: image: "micahparks/magiclinksdevnop" diff --git a/config.quickstart.json b/config.quickstart.json index 193f20d..bd813db 100644 --- a/config.quickstart.json +++ b/config.quickstart.json @@ -1,14 +1,14 @@ { "server": { - "adminConfig": [ + "adminCreateParams": [ { "apiKey": "40084740-0bc3-455d-b298-e23a31561580", "aud": "ad9e9d84-92ea-4f07-bac9-5d898d59c83b", "uuid": "1e079d6d-a8b9-4065-aa8d-86906accd211", - "serviceAccountCreateArgs": {} + "serviceAccountCreateParams": {} } ], - "baseURL": "http://localhost:8080/api/v1/", + "baseURL": "http://localhost:8080/api/v2/", "iss": "http://localhost:8080" }, "storage": { diff --git a/config.test.json b/config.test.json index 407a213..c241f92 100644 --- a/config.test.json +++ b/config.test.json @@ -9,15 +9,15 @@ "refillRate": 0 }, "server": { - "adminConfig": [ + "adminCreateParams": [ { "apiKey": "40084740-0bc3-455d-b298-e23a31561580", "aud": "ad9e9d84-92ea-4f07-bac9-5d898d59c83b", "uuid": "1e079d6d-a8b9-4065-aa8d-86906accd211", - "serviceAccountCreateArgs": {} + "serviceAccountCreateParams": {} } ], - "baseURL": "http://localtest/api/v1/", + "baseURL": "http://localtest/api/v2/", "iss": "testIssuer", "jwks": { "ignoreDefault": false diff --git a/config/config.go b/config/config.go index 85249c1..9f5049b 100644 --- a/config/config.go +++ b/config/config.go @@ -24,7 +24,7 @@ type LogLevel string // Config is the configuration for the magiclinksdev server. type Config struct { - AdminConfig []model.AdminCreateArgs `json:"adminConfig"` + AdminCreateParams []model.AdminCreateParams `json:"adminCreateParams"` BaseURL *jt.JSONType[*url.URL] `json:"baseURL"` Iss string `json:"iss"` JWKS JWKS `json:"jwks"` diff --git a/constants.go b/constants.go index c2e4d49..0902b6a 100644 --- a/constants.go +++ b/constants.go @@ -1,8 +1,15 @@ package magiclinksdev +import ( + "errors" + "time" +) + const ( // ContentTypeJSON is the content type for JSON. ContentTypeJSON = "application/json" + // DefaultOTPLength is the default length for OTPs. + DefaultOTPLength = 6 // DefaultRelativePathRedirect is the default relative path for redirecting. DefaultRelativePathRedirect = "redirect" // HeaderContentType is the content type header. @@ -15,6 +22,8 @@ const ( LogRequestBody = "requestBody" // LogResponseBody is key for logging the response body. LogResponseBody = "responseBody" + // Over250Years is the maximum duration for this project. Restriction derived from Golang's time.Duration. + Over250Years = 250 * 366 * 24 * time.Hour // ResponseInternalServerError is the response for internal server errors. ResponseInternalServerError = "Internal server error." // ResponseTooManyRequests is the response for too many requests. @@ -23,6 +32,10 @@ const ( ResponseUnauthorized = "Unauthorized." ) +var ( + ErrParams = errors.New("invalid parameters") +) + func Ptr[T any](v T) *T { return &v } diff --git a/email/html.gohtml b/email/html.gohtml deleted file mode 100644 index 5cce72b..0000000 --- a/email/html.gohtml +++ /dev/null @@ -1,166 +0,0 @@ -{{- /*gotype: github.com/MicahParks/magiclinksdev/email.TemplateData*/ -}} - - - - - - - - - - {{.Meta.MSOHead}} - {{.Meta.HTMLTitle}} - - - -
- {{.Meta.HTMLInstruction}} - ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ - ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ - ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ - ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ - ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ - ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ - ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ - ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ - ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ -
-
- - - - -
- - {{- if .LogoImageURL -}} - - - - {{- end -}} - - - - -
-
- - diff --git a/email/interface.go b/email/interface.go index eaf688e..d403ecc 100644 --- a/email/interface.go +++ b/email/interface.go @@ -11,12 +11,13 @@ var ErrProvider = errors.New("error with email provider") // Provider is the interface for an email provider. type Provider interface { - Send(ctx context.Context, e Email) error + SendMagicLink(ctx context.Context, e Email) error + SendOTP(ctx context.Context, e Email) error } // Email is the model for an email. type Email struct { Subject string - TemplateData TemplateData + TemplateData any To *mail.Address } diff --git a/email/magic_link_html.gohtml b/email/magic_link_html.gohtml new file mode 100644 index 0000000..dbb5177 --- /dev/null +++ b/email/magic_link_html.gohtml @@ -0,0 +1,128 @@ +{{- /*gotype: github.com/MicahParks/magiclinksdev/email.MagicLinkTemplateData*/ -}} + + + + + + + + + + {{.Meta.MSOHead}} + {{.Meta.HTMLTitle}} + + + +
+ {{.Meta.HTMLInstruction}} +  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏ +
+
+
+ + + + +
+ {{- if .LogoImageURL}} +
+ + {{.LogoAltText}} + +
+ {{- end}} + + + + + + + + + + +
+ {{- if .Greeting}} +

+ {{.Greeting}} +

+ {{- end}} +

+ {{.Title}} +

+ {{- if .Subtitle}} +

+ {{.Subtitle}} +

+ {{- end}} +
+ +
+

+ This link expires in {{.Expiration}}. +

+

+ Support will never ask for anything in this email. +

+

+ Never forward this email or share its contents with anyone. +

+ {{- if .ReCATPTCHA}} +

+ This service is protected by reCAPTCHA and the Google + Privacy Policy and + Terms of Service apply. +

+ {{- end}} +
+

+ Powered by + magiclinks.dev +

+
+
+
+
+ + \ No newline at end of file diff --git a/email/text.gotxt b/email/magic_link_text.gotxt similarity index 75% rename from email/text.gotxt rename to email/magic_link_text.gotxt index faa21cb..660967b 100644 --- a/email/text.gotxt +++ b/email/magic_link_text.gotxt @@ -1,16 +1,17 @@ -{{- /*gotype: github.com/MicahParks/magiclinksdev/email.TemplateData*/ -}} +{{- /*gotype: github.com/MicahParks/magiclinksdev/email.MagicLinkTemplateData*/ -}} {{if .Greeting}}{{.Greeting}}{{end -}} {{.Title}} {{if .Subtitle}}{{.Subtitle}}{{end -}} {{.MagicLink}} -Never forward this email or share its contents with anyone. This link expires in {{.Expiration}}. - -Powered by magiclinks.dev +Support will never ask for anything in this email. +Never forward this email or share its contents with anyone. {{if .ReCATPTCHA}} This service is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply. https://policies.google.com/privacy https://policies.google.com/terms {{end -}} + +Powered by magiclinks.dev diff --git a/email/multi.go b/email/multi.go index f2c1ba6..4886f06 100644 --- a/email/multi.go +++ b/email/multi.go @@ -34,11 +34,27 @@ func NewMultiProvider(providers []Provider, options MultiProviderOptions) (Provi return m, nil } -// Send sends an email using the multiple email provider. -func (m multiProvider) Send(ctx context.Context, e Email) error { +func (m multiProvider) SendMagicLink(ctx context.Context, e Email) error { combinedErr := fmt.Errorf("%w: no providers were able to send the email", ErrProvider) for _, p := range m.providers { - err := p.Send(ctx, e) + err := p.SendMagicLink(ctx, e) + if err == nil { + return nil + } + m.logger.ErrorContext(ctx, "Failed to send email with using multi-provider. Attempting with next provider.", + mld.LogErr, err, + ) + combinedErr = fmt.Errorf("%w: %w", combinedErr, err) + } + m.logger.ErrorContext(ctx, "Failed to send email with using multi-provider. No providers were able to send the email.", + mld.LogErr, combinedErr, + ) + return combinedErr +} +func (m multiProvider) SendOTP(ctx context.Context, e Email) error { + combinedErr := fmt.Errorf("%w: no providers were able to send the email", ErrProvider) + for _, p := range m.providers { + err := p.SendOTP(ctx, e) if err == nil { return nil } diff --git a/email/otp_html.gohtml b/email/otp_html.gohtml new file mode 100644 index 0000000..6989584 --- /dev/null +++ b/email/otp_html.gohtml @@ -0,0 +1,110 @@ +{{- /*gotype: github.com/MicahParks/magiclinksdev/email.OTPTemplateData*/ -}} + + + + + + + + + + {{.Meta.MSOHead}} + {{.Meta.HTMLTitle}} + + + +
+ {{.Meta.HTMLInstruction}} +  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏ +
+
+
+ + + + +
+ {{- if .LogoImageURL}} +
+ + {{.LogoAltText}} + +
+ {{- end}} + + + + + + + + + + +
+ {{- if .Greeting}} +

+ {{.Greeting}} +

+ {{- end}} +

+ {{.Title}} +

+ {{- if .Subtitle}} +

+ {{.Subtitle}} +

+ {{- end}} +
+
+
+ {{.OTP}} +
+
+
+

+ This OTP expires in {{.Expiration}}. +

+

+ Support will never ask for anything in this email. +

+

+ Never forward this email or share its contents with anyone. +

+
+

+ Powered by + magiclinks.dev +

+
+
+
+
+ + \ No newline at end of file diff --git a/email/otp_text.gotxt b/email/otp_text.gotxt new file mode 100644 index 0000000..a1d3d6c --- /dev/null +++ b/email/otp_text.gotxt @@ -0,0 +1,14 @@ +{{- /*gotype: github.com/MicahParks/magiclinksdev/email.OTPTemplateData*/ -}} +{{if .Greeting}}{{.Greeting}}{{end -}} + +{{.Title}} +{{if .Subtitle}}{{.Subtitle}}{{end -}} + +Your One-Time Password (OTP) is: +{{.OTP}} + +This OTP expires in {{.Expiration}}. +Support will never ask for anything in this email. +Never forward this email or share its contents with anyone. + +Powered by magiclinks.dev diff --git a/email/sendgrid/sendgrid.go b/email/sendgrid/sendgrid.go index 921c477..555aed8 100644 --- a/email/sendgrid/sendgrid.go +++ b/email/sendgrid/sendgrid.go @@ -34,35 +34,47 @@ func (s Config) DefaultsAndValidate() (Config, error) { } type sendGrid struct { - client *sendgrid.Client - from *netMail.Address - htmlTmpl *template.Template - textTmpl *textTemplate.Template + client *sendgrid.Client + from *netMail.Address + magicLinkHTMLTmpl *template.Template + magicLinkTxtTmpl *textTemplate.Template + oTPHTMLTmpl *template.Template + oTPTxtTmpl *textTemplate.Template } // NewProvider creates a new SendGrid email provider. func NewProvider(conf Config) (email.Provider, error) { client := sendgrid.NewSendClient(conf.APIKey) - htmlTmpl := template.Must(template.New("").Parse(email.HTMLTemplate)) - textTmpl := textTemplate.Must(textTemplate.New("").Parse(email.TextTemplate)) + magicLinkHTMLTmpl := template.Must(template.New("").Parse(email.MagicLinkHTMLTemplate)) + magicLinkTxtTmpl := textTemplate.Must(textTemplate.New("").Parse(email.MagicLinkTextTemplate)) + otpHTMLTmpl := template.Must(template.New("").Parse(email.OTPHTMLTemplate)) + otpTxtTmpl := textTemplate.Must(textTemplate.New("").Parse(email.OTPTextTemplate)) s := sendGrid{ - client: client, - from: conf.FromEmail.Get(), - htmlTmpl: htmlTmpl, - textTmpl: textTmpl, + client: client, + from: conf.FromEmail.Get(), + magicLinkHTMLTmpl: magicLinkHTMLTmpl, + magicLinkTxtTmpl: magicLinkTxtTmpl, + oTPHTMLTmpl: otpHTMLTmpl, + oTPTxtTmpl: otpTxtTmpl, } return s, nil } -// Send implements the email.Provider interface. -func (s sendGrid) Send(ctx context.Context, e email.Email) error { +func (s sendGrid) SendMagicLink(ctx context.Context, e email.Email) error { + return s.sendEmail(ctx, e, s.magicLinkHTMLTmpl, s.magicLinkTxtTmpl) +} +func (s sendGrid) SendOTP(ctx context.Context, e email.Email) error { + return s.sendEmail(ctx, e, s.oTPHTMLTmpl, s.oTPTxtTmpl) +} + +func (s sendGrid) sendEmail(ctx context.Context, e email.Email, htmlTmpl *template.Template, txtTmpl *textTemplate.Template) error { htmlBuf := bytes.NewBuffer(nil) - err := s.htmlTmpl.Execute(htmlBuf, e.TemplateData) + err := htmlTmpl.Execute(htmlBuf, e.TemplateData) if err != nil { return fmt.Errorf("failed to execute template for HTML email: %w", err) } textBuf := bytes.NewBuffer(nil) - err = s.textTmpl.Execute(textBuf, e.TemplateData) + err = txtTmpl.Execute(textBuf, e.TemplateData) if err != nil { return fmt.Errorf("failed to execute template for text email: %w", err) } diff --git a/email/ses/ses.go b/email/ses/ses.go index f0f8eee..848d364 100644 --- a/email/ses/ses.go +++ b/email/ses/ses.go @@ -54,10 +54,12 @@ func (c Config) DefaultsAndValidate() (Config, error) { // SES is an email provider that uses AWS SES. type SES struct { - from *netMail.Address - htmlTmpl *template.Template - ses *ses.SES - textTmpl *textTemplate.Template + from *netMail.Address + magicLinkHTMLTmpl *template.Template + magicLinkTxtTmpl *textTemplate.Template + oTPHTMLTmpl *template.Template + oTPTxtTmpl *textTemplate.Template + ses *ses.SES } // NewProvider creates a new SES provider. It will create an AWS session using the provided configuration. @@ -82,26 +84,36 @@ func NewProvider(conf Config) (SES, error) { // NewProviderInitialized creates a new SES provider with an initialized configuration. func NewProviderInitialized(conf InitializedConfig, svc *ses.SES) (SES, error) { - htmlTmpl := template.Must(template.New("").Parse(email.HTMLTemplate)) - textTmpl := textTemplate.Must(textTemplate.New("").Parse(email.TextTemplate)) + magicLinkHTMLTmpl := template.Must(template.New("").Parse(email.MagicLinkHTMLTemplate)) + magicLinkTxtTML := textTemplate.Must(textTemplate.New("").Parse(email.MagicLinkTextTemplate)) + otpHTMLTmpl := template.Must(template.New("").Parse(email.OTPHTMLTemplate)) + otpTxtTmpl := textTemplate.Must(textTemplate.New("").Parse(email.OTPTextTemplate)) s := SES{ - from: conf.FromEmail, - htmlTmpl: htmlTmpl, - ses: svc, - textTmpl: textTmpl, + from: conf.FromEmail, + magicLinkHTMLTmpl: magicLinkHTMLTmpl, + magicLinkTxtTmpl: magicLinkTxtTML, + oTPHTMLTmpl: otpHTMLTmpl, + oTPTxtTmpl: otpTxtTmpl, + ses: svc, } return s, nil } -// Send implements the email.Provider interface. -func (s SES) Send(ctx context.Context, e email.Email) error { +func (s SES) SendMagicLink(ctx context.Context, e email.Email) error { + return s.sendEmail(ctx, e, s.magicLinkHTMLTmpl, s.magicLinkTxtTmpl) +} +func (s SES) SendOTP(ctx context.Context, e email.Email) error { + return s.sendEmail(ctx, e, s.oTPHTMLTmpl, s.oTPTxtTmpl) +} + +func (s SES) sendEmail(ctx context.Context, e email.Email, htmlTmpl *template.Template, txtTmpl *textTemplate.Template) error { htmlBuf := bytes.NewBuffer(nil) - err := s.htmlTmpl.Execute(htmlBuf, e.TemplateData) + err := htmlTmpl.Execute(htmlBuf, e.TemplateData) if err != nil { return fmt.Errorf("failed to execute template for HTML email: %w", err) } textBuf := bytes.NewBuffer(nil) - err = s.textTmpl.Execute(textBuf, e.TemplateData) + err = txtTmpl.Execute(textBuf, e.TemplateData) if err != nil { return fmt.Errorf("failed to execute template for text email: %w", err) } diff --git a/email/template.go b/email/template.go index b2d9358..6eecb1d 100644 --- a/email/template.go +++ b/email/template.go @@ -8,14 +8,14 @@ import ( const ( // MSOButtonStop is the HTML to stop MSO button spacing. MSOButtonStop = `` + + ` // MSOButtonStart is the HTML to start MSO button spacing. MSOButtonStart = `` + + ` // MSOHead is the HTML to start MSO head. - MSOHead = `` ) -// HTMLTemplate is the HTML template for the email. +// MagicLinkHTMLTemplate is the HTML template for the magic link email. // -//go:embed html.gohtml -var HTMLTemplate string +//go:embed magic_link_html.gohtml +var MagicLinkHTMLTemplate string -// TextTemplate is the text template for the email. +// MagicLinkTextTemplate is the text template for the magic link email. // -//go:embed text.gotxt -var TextTemplate string +//go:embed magic_link_text.gotxt +var MagicLinkTextTemplate string -// TemplateData is the data for the email templates. -type TemplateData struct { +// MagicLinkTemplateData is the data for the magic link email template. +type MagicLinkTemplateData struct { ButtonText string Expiration string Greeting string @@ -54,6 +54,30 @@ type TemplateData struct { ReCATPTCHA bool } +// OTPHTMLTemplate is the HTML template for the OTP email. +// +//go:embed otp_html.gohtml +var OTPHTMLTemplate string + +// OTPTextTemplate is the text template for the OTP email. +// +//go:embed otp_text.gotxt +var OTPTextTemplate string + +// OTPTemplateData is the data for the OTP email template. +type OTPTemplateData struct { + Expiration string + Greeting string + MagicLink string + Meta TemplateMetadata + OTP string + Subtitle string + Title string + LogoImageURL string + LogoClickURL string + LogoAltText string +} + // TemplateMetadata contains non-configurable metadata for the email templates. type TemplateMetadata struct { HTMLInstruction string diff --git a/examples/client/README.md b/examples/client/README.md index 54f6864..ac63370 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -1 +1,2 @@ -These examples require a running server. \ No newline at end of file +For instructions getting started with these examples, please refer to +the [documentation website quickstart guide](https://docs.magiclinks.dev/self-host-quickstart) diff --git a/examples/client/jwt_create/main.go b/examples/client/jwt_create/main.go new file mode 100644 index 0000000..4659084 --- /dev/null +++ b/examples/client/jwt_create/main.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "encoding/json" + "log/slog" + "os" + "time" + + mld "github.com/MicahParks/magiclinksdev" + "github.com/MicahParks/magiclinksdev/client" + "github.com/MicahParks/magiclinksdev/mldtest" + "github.com/MicahParks/magiclinksdev/model" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + logger := slog.Default() + + c, err := client.New(mldtest.APIKey, mldtest.Aud, mldtest.BaseURL, mldtest.Iss, client.Options{}) + if err != nil { + logger.Error("Failed to create client.", + mld.LogErr, err, + ) + } + + req := model.JWTCreateRequest{ + JWTCreateParams: model.JWTCreateParams{ + Claims: map[string]string{ + "foo": "bar", + }, + }, + } + resp, mldErr, err := c.JWTCreate(ctx, req) + if err != nil { + if mldErr.Code != 0 { + logger = logger.With( + "code", mldErr.Code, + "message", mldErr.Message, + "requestUUID", mldErr.RequestMetadata.UUID, + ) + } + logger.Error("Failed to create JWT.", + mld.LogErr, err, + ) + os.Exit(1) + } + + data, err := json.MarshalIndent(resp, "", " ") + if err != nil { + logger.ErrorContext(ctx, "Failed to marshal response.", + mld.LogErr, err, + ) + os.Exit(1) + } + + println(string(data)) +} diff --git a/examples/client/jwt_validate/main.go b/examples/client/jwt_validate/main.go index 2a707b8..bcfda97 100644 --- a/examples/client/jwt_validate/main.go +++ b/examples/client/jwt_validate/main.go @@ -26,6 +26,7 @@ func main() { logger.Error("Failed to validate JWT. This is normal for the default example.", mld.LogErr, err, ) + return } logger.Info("JWT is valid.", diff --git a/examples/client/send_link/main.go b/examples/client/magic_link_create/main.go similarity index 83% rename from examples/client/send_link/main.go rename to examples/client/magic_link_create/main.go index 4348076..f75037a 100644 --- a/examples/client/send_link/main.go +++ b/examples/client/magic_link_create/main.go @@ -36,18 +36,18 @@ func main() { os.Exit(1) } - req := model.LinkCreateRequest{ - LinkArgs: model.LinkCreateArgs{ - JWTCreateArgs: model.JWTCreateArgs{ - JWTClaims: claims, - JWTLifespanSeconds: 5, + req := model.MagicLinkCreateRequest{ + MagicLinkCreateParams: model.MagicLinkCreateParams{ + JWTCreateParams: model.JWTCreateParams{ + Claims: claims, + LifespanSeconds: 5, }, - LinkLifespan: 100, + LifespanSeconds: 100, RedirectQueryKey: "", RedirectURL: "https://jwtdebug.micahparks.com", }, } - resp, mldErr, err := c.LinkCreate(ctx, req) + resp, mldErr, err := c.MagicLinkCreate(ctx, req) if err != nil { if mldErr.Code != 0 { logger = logger.With( diff --git a/examples/client/send_email_link/main.go b/examples/client/magic_link_email_create/main.go similarity index 83% rename from examples/client/send_email_link/main.go rename to examples/client/magic_link_email_create/main.go index fcd7902..f90dfff 100644 --- a/examples/client/send_email_link/main.go +++ b/examples/client/magic_link_email_create/main.go @@ -36,8 +36,8 @@ func main() { os.Exit(1) } - req := model.EmailLinkCreateRequest{ - EmailArgs: model.EmailLinkCreateArgs{ + req := model.MagicLinkEmailCreateRequest{ + MagicLinkEmailCreateParams: model.MagicLinkEmailCreateParams{ ButtonText: "Log in", Greeting: "Hello John Doe,", LogoClickURL: "https://magiclinks.dev", @@ -49,17 +49,17 @@ func main() { ToEmail: "johndoe@example.com", ToName: "John Doe", }, - LinkArgs: model.LinkCreateArgs{ - JWTCreateArgs: model.JWTCreateArgs{ - JWTClaims: claims, - JWTLifespanSeconds: 5, + MagicLinkCreateParams: model.MagicLinkCreateParams{ + JWTCreateParams: model.JWTCreateParams{ + Claims: claims, + LifespanSeconds: 5, }, - LinkLifespan: 60 * 60, + LifespanSeconds: 60 * 60, RedirectQueryKey: "", RedirectURL: "https://jwtdebug.micahparks.com", }, } - resp, mldErr, err := c.EmailLinkCreate(ctx, req) + resp, mldErr, err := c.MagicLinkEmailCreate(ctx, req) if err != nil { if mldErr.Code != 0 { logger = logger.With( diff --git a/examples/client/otp_create/main.go b/examples/client/otp_create/main.go new file mode 100644 index 0000000..0ebd5f6 --- /dev/null +++ b/examples/client/otp_create/main.go @@ -0,0 +1,62 @@ +package main + +import ( + "context" + _ "embed" + "encoding/json" + "log/slog" + "os" + "time" + + mld "github.com/MicahParks/magiclinksdev" + "github.com/MicahParks/magiclinksdev/client" + "github.com/MicahParks/magiclinksdev/mldtest" + "github.com/MicahParks/magiclinksdev/model" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + logger := slog.Default() + + c, err := client.New(mldtest.APIKey, mldtest.Aud, mldtest.BaseURL, mldtest.Iss, client.Options{}) + if err != nil { + logger.ErrorContext(ctx, "Failed to create client.", + mld.LogErr, err, + ) + os.Exit(1) + } + + req := model.OTPCreateRequest{ + OTPCreateParams: model.OTPCreateParams{ + CharSetNumeric: true, + Length: 0, + LifespanSeconds: 0, + }, + } + resp, mldErr, err := c.OTPCreate(ctx, req) + if err != nil { + if mldErr.Code != 0 { + logger = logger.With( + "code", mldErr.Code, + "message", mldErr.Message, + "requestUUID", mldErr.RequestMetadata.UUID, + ) + } + logger.ErrorContext(ctx, "Failed to create OTP.", + mld.LogErr, err, + ) + os.Exit(1) + } + + data, err := json.MarshalIndent(resp, "", " ") + if err != nil { + logger.ErrorContext(ctx, "Failed to marshal response.", + mld.LogErr, err, + ) + os.Exit(1) + } + + println(string(data)) +} diff --git a/examples/client/otp_email_create/main.go b/examples/client/otp_email_create/main.go new file mode 100644 index 0000000..ed527d0 --- /dev/null +++ b/examples/client/otp_email_create/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "context" + _ "embed" + "encoding/json" + "log/slog" + "os" + "time" + + mld "github.com/MicahParks/magiclinksdev" + "github.com/MicahParks/magiclinksdev/client" + "github.com/MicahParks/magiclinksdev/mldtest" + "github.com/MicahParks/magiclinksdev/model" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + logger := slog.Default() + + c, err := client.New(mldtest.APIKey, mldtest.Aud, mldtest.BaseURL, mldtest.Iss, client.Options{}) + if err != nil { + logger.ErrorContext(ctx, "Failed to create client.", + mld.LogErr, err, + ) + os.Exit(1) + } + + req := model.OTPEmailCreateRequest{ + OTPCreateParams: model.OTPCreateParams{ + CharSetAlphaLower: false, + CharSetAlphaUpper: false, + CharSetNumeric: true, + Length: 0, + LifespanSeconds: 0, + }, + OTPEmailCreateParams: model.OTPEmailCreateParams{ + Greeting: "Hello John Doe,", + LogoClickURL: "https://magiclinks.dev", + LogoImageURL: "https://magiclinks.dev/typeface-gray.png", + ServiceName: "magiclinks.dev", + Subject: "Verify your email - magiclinks.dev", + SubTitle: "Use this One-Time Password (OTP) to verify your email address.", + Title: "Your OTP is below.", + ToEmail: "johndoe@example.com", + ToName: "John Doe", + }, + } + resp, mldErr, err := c.OTPEmailCreate(ctx, req) + if err != nil { + if mldErr.Code != 0 { + logger = logger.With( + "code", mldErr.Code, + "message", mldErr.Message, + "requestUUID", mldErr.RequestMetadata.UUID, + ) + } + logger.ErrorContext(ctx, "Failed to create OTP.", + mld.LogErr, err, + ) + os.Exit(1) + } + + data, err := json.MarshalIndent(resp, "", " ") + if err != nil { + logger.ErrorContext(ctx, "Failed to marshal response.", + mld.LogErr, err, + ) + os.Exit(1) + } + + println(string(data)) +} diff --git a/examples/client/otp_validate/main.go b/examples/client/otp_validate/main.go new file mode 100644 index 0000000..41f5b54 --- /dev/null +++ b/examples/client/otp_validate/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "log/slog" + "os" + "time" + + mld "github.com/MicahParks/magiclinksdev" + "github.com/MicahParks/magiclinksdev/client" + "github.com/MicahParks/magiclinksdev/mldtest" + "github.com/MicahParks/magiclinksdev/model" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + logger := slog.Default() + + var ( + id string + otp string + ) + flag.StringVar(&id, "id", "", "The ID of the OTP to validate.") + flag.StringVar(&otp, "otp", "", "The OTP to validate.") + flag.Parse() + if id == "" || otp == "" { + flag.Usage() + os.Exit(1) + } + + c, err := client.New(mldtest.APIKey, mldtest.Aud, mldtest.BaseURL, mldtest.Iss, client.Options{}) + if err != nil { + logger.ErrorContext(ctx, "Failed to create client.", + mld.LogErr, err, + ) + os.Exit(1) + } + + req := model.OTPValidateRequest{ + OTPValidateParams: model.OTPValidateParams{ + ID: id, + OTP: otp, + }, + } + resp, mldErr, err := c.OTPValidate(ctx, req) + if err != nil { + if mldErr.Code != 0 { + logger = logger.With( + "code", mldErr.Code, + "message", mldErr.Message, + "requestUUID", mldErr.RequestMetadata.UUID, + ) + } + logger.ErrorContext(ctx, "Failed to validate OTP.", + mld.LogErr, err, + ) + os.Exit(1) + } + + data, err := json.MarshalIndent(resp, "", " ") + if err != nil { + logger.ErrorContext(ctx, "Failed to marshal response.", + mld.LogErr, err, + ) + os.Exit(1) + } + + println(string(data)) +} diff --git a/examples/client/create_service_account/main.go b/examples/client/service_account_create/main.go similarity index 95% rename from examples/client/create_service_account/main.go rename to examples/client/service_account_create/main.go index 9cee8c8..fec6123 100644 --- a/examples/client/create_service_account/main.go +++ b/examples/client/service_account_create/main.go @@ -28,7 +28,7 @@ func main() { } req := model.ServiceAccountCreateRequest{ - CreateServiceAccountArgs: model.ServiceAccountCreateArgs{}, + ServiceAccountCreateParams: model.ServiceAccountCreateParams{}, } resp, mldErr, err := c.ServiceAccountCreate(ctx, req) if err != nil { diff --git a/go.mod b/go.mod index 79b209a..057fdb4 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 golang.org/x/mod v0.21.0 - golang.org/x/time v0.6.0 + golang.org/x/time v0.7.0 ) require ( @@ -28,7 +28,7 @@ require ( github.com/sendgrid/rest v2.6.9+incompatible // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - golang.org/x/crypto v0.27.0 // indirect + golang.org/x/crypto v0.28.0 // indirect golang.org/x/sync v0.8.0 // indirect - golang.org/x/text v0.18.0 // indirect + golang.org/x/text v0.19.0 // indirect ) diff --git a/go.sum b/go.sum index 3c8fa75..f4ba9c1 100644 --- a/go.sum +++ b/go.sum @@ -48,18 +48,18 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= -golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= -golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= -golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= +golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/handle/email_link.go b/handle/email_link.go deleted file mode 100644 index 03ec32c..0000000 --- a/handle/email_link.go +++ /dev/null @@ -1,69 +0,0 @@ -package handle - -import ( - "context" - "fmt" - - "github.com/google/uuid" - - "github.com/MicahParks/magiclinksdev/config" - "github.com/MicahParks/magiclinksdev/email" - "github.com/MicahParks/magiclinksdev/model" - "github.com/MicahParks/magiclinksdev/network/middleware/ctxkey" -) - -// HandleEmailLinkCreate handles the email link creation endpoint. -func (s *Server) HandleEmailLinkCreate(ctx context.Context, req model.ValidEmailLinkCreateRequest) (model.EmailLinkCreateResponse, error) { - emailArgs := req.EmailArgs - linkArgs := req.LinkArgs - - magicLinkResp, err := s.createLink(ctx, linkArgs) - if err != nil { - return model.EmailLinkCreateResponse{}, fmt.Errorf("failed to create magic link: %w", err) - } - - meta := email.TemplateMetadata{ - HTMLInstruction: fmt.Sprintf("You've been sent a magic link from %s.", emailArgs.ServiceName), - HTMLTitle: fmt.Sprintf("Magic link from %s", emailArgs.ServiceName), - MSOButtonStop: email.MSOButtonStop, - MSOButtonStart: email.MSOButtonStart, - MSOHead: email.MSOHead, - } - tData := email.TemplateData{ - ButtonText: emailArgs.ButtonText, - Expiration: linkArgs.LinkLifespan.String(), - Greeting: emailArgs.Greeting, - LogoImageURL: emailArgs.LogoImageURL, - LogoClickURL: emailArgs.LogoClickURL, - LogoAltText: "logo", - MagicLink: magicLinkResp.MagicLink.String(), - Meta: meta, - Subtitle: emailArgs.SubTitle, - Title: emailArgs.Title, - ReCATPTCHA: s.Config.PreventRobots.Method == config.PreventRobotsReCAPTCHAV3, - } - e := email.Email{ - Subject: emailArgs.Subject, - TemplateData: tData, - To: emailArgs.ToEmail, - } - err = s.EmailProvider.Send(ctx, e) - if err != nil { - return model.EmailLinkCreateResponse{}, fmt.Errorf("failed to send email: %w", err) - } - - linkCreateResponse := model.LinkCreateResults{ - MagicLink: magicLinkResp.MagicLink.String(), - Secret: magicLinkResp.Secret, - } - resp := model.EmailLinkCreateResponse{ - EmailLinkCreateResults: model.EmailLinkCreateResults{ - LinkCreateResults: linkCreateResponse, - }, - RequestMetadata: model.RequestMetadata{ - UUID: ctx.Value(ctxkey.RequestUUID).(uuid.UUID), - }, - } - - return resp, nil -} diff --git a/handle/jwt_create.go b/handle/jwt_create.go index 76b768d..32e15ae 100644 --- a/handle/jwt_create.go +++ b/handle/jwt_create.go @@ -27,19 +27,18 @@ var ( ErrJWTAlgNotFound = errors.New("JWT alg not found") ) -// HandleJWTCreate handles the creation of a JWT. func (s *Server) HandleJWTCreate(ctx context.Context, req model.ValidJWTCreateRequest) (model.JWTCreateResponse, error) { - jwtCreateArgs := req.JWTCreateArgs + jwtCreateParams := req.JWTCreateParams - edited, err := s.addRegisteredClaims(ctx, jwtCreateArgs) + edited, err := s.addRegisteredClaims(ctx, jwtCreateParams) if err != nil { return model.JWTCreateResponse{}, fmt.Errorf("failed to add registered claims to JWT claims: %w", err) } options := storage.ReadSigningKeyOptions{ - JWTAlg: jwtCreateArgs.JWTAlg, + JWTAlg: jwtCreateParams.Alg, } - jwk, err := s.Store.ReadSigningKey(ctx, options) + jwk, err := s.Store.SigningKeyRead(ctx, options) if err != nil { if errors.Is(err, storage.ErrNotFound) { return model.JWTCreateResponse{}, fmt.Errorf("could not fing signing key with specified JWT alg: %w", ErrJWTAlgNotFound) @@ -70,13 +69,13 @@ func (s *Server) HandleJWTCreate(ctx context.Context, req model.ValidJWTCreateRe return response, nil } -func (s *Server) addRegisteredClaims(ctx context.Context, args model.ValidJWTCreateArgs) (json.RawMessage, error) { +func (s *Server) addRegisteredClaims(ctx context.Context, args model.ValidJWTCreateParams) (json.RawMessage, error) { sa, ok := ctx.Value(ctxkey.ServiceAccount).(model.ServiceAccount) if !ok { return nil, fmt.Errorf("%w: service account context not found", ctxkey.ErrCtxKey) } - valid := json.Valid(args.JWTClaims) + valid := json.Valid(args.Claims) if !valid { return nil, fmt.Errorf("%w: invalid JSON for JWT claims", model.ErrInvalidModel) } @@ -91,7 +90,7 @@ func (s *Server) addRegisteredClaims(ctx context.Context, args model.ValidJWTCre Issuer: s.Config.Iss, Subject: "", // Don't set. Audience: jwt.ClaimStrings{sa.Aud.String()}, - ExpiresAt: jwt.NewNumericDate(n.Add(args.JWTLifespan)), + ExpiresAt: jwt.NewNumericDate(n.Add(args.Lifespan)), NotBefore: now, IssuedAt: now, ID: u.String(), @@ -113,8 +112,8 @@ func (s *Server) addRegisteredClaims(ctx context.Context, args model.ValidJWTCre magiclinksdev.AttrJti, } - edited := make(json.RawMessage, len(args.JWTClaims)) - copy(edited, args.JWTClaims) + edited := make(json.RawMessage, len(args.Claims)) + copy(edited, args.Claims) for _, attr := range rfc5119 { if gjson.GetBytes(edited, attr).Exists() { return nil, fmt.Errorf("%w: %s", ErrRegisteredClaimProvided, attr) @@ -136,12 +135,12 @@ func (s *Server) addRegisteredClaims(ctx context.Context, args model.ValidJWTCre return edited, nil } -func (s *Server) createLinkArgs(ctx context.Context, args model.ValidLinkCreateArgs) (magiclink.CreateArgs, error) { - var createArgs magiclink.CreateArgs +func (s *Server) createLinkParams(ctx context.Context, args model.ValidMagicLinkCreateParams) (magiclink.CreateParams, error) { + var createParams magiclink.CreateParams - edited, err := s.addRegisteredClaims(ctx, args.JWTCreateArgs) + edited, err := s.addRegisteredClaims(ctx, args.JWTCreateParams) if err != nil { - return createArgs, fmt.Errorf("failed to add registered claims to JWT claims: %w", err) + return createParams, fmt.Errorf("failed to add registered claims to JWT claims: %w", err) } claims := magiclinksdev.SigningBytesClaims{ @@ -149,24 +148,24 @@ func (s *Server) createLinkArgs(ctx context.Context, args model.ValidLinkCreateA } options := storage.ReadSigningKeyOptions{ - JWTAlg: args.JWTCreateArgs.JWTAlg, + JWTAlg: args.JWTCreateParams.Alg, } - jwk, err := s.Store.ReadSigningKey(ctx, options) + jwk, err := s.Store.SigningKeyRead(ctx, options) if err != nil { if errors.Is(err, storage.ErrNotFound) { - return createArgs, fmt.Errorf("could not fing signing key with specified JWT alg: %w", ErrJWTAlgNotFound) + return createParams, fmt.Errorf("could not fing signing key with specified JWT alg: %w", ErrJWTAlgNotFound) } - return createArgs, fmt.Errorf("failed to get JWT signing key: %w", err) + return createParams, fmt.Errorf("failed to get JWT signing key: %w", err) } kID := jwk.Marshal().KID - createArgs = magiclink.CreateArgs{ - Expires: time.Now().Add(args.LinkLifespan), + createParams = magiclink.CreateParams{ + Expires: time.Now().Add(args.Lifespan), JWTClaims: claims, JWTKeyID: &kID, RedirectQueryKey: args.RedirectQueryKey, RedirectURL: args.RedirectURL, } - return createArgs, nil + return createParams, nil } diff --git a/handle/jwt_valdiate.go b/handle/jwt_valdiate.go index 62bc80c..6366615 100644 --- a/handle/jwt_valdiate.go +++ b/handle/jwt_valdiate.go @@ -21,12 +21,11 @@ var ( ErrToken = errors.New("JWT invalid") ) -// HandleJWTValidate handles the JWT validation endpoint. func (s *Server) HandleJWTValidate(ctx context.Context, req model.ValidJWTValidateRequest) (model.JWTValidateResponse, error) { - jwtValidateArgs := req.JWTValidateArgs + jwtValidateParams := req.JWTValidateParams sa := ctx.Value(ctxkey.ServiceAccount).(model.ServiceAccount) - token, err := jwt.Parse(jwtValidateArgs.JWT, func(token *jwt.Token) (any, error) { + token, err := jwt.Parse(jwtValidateParams.JWT, func(token *jwt.Token) (any, error) { jwksBytes, err := s.JWKS.JSONPublic(ctx) // Change to JSONPrivate if HMAC support is added. if err != nil { return nil, fmt.Errorf("failed to get JWKS JSON: %w", err) diff --git a/handle/link.go b/handle/link.go deleted file mode 100644 index 0dfed4e..0000000 --- a/handle/link.go +++ /dev/null @@ -1,49 +0,0 @@ -package handle - -import ( - "context" - "fmt" - - "github.com/google/uuid" - - "github.com/MicahParks/magiclinksdev/magiclink" - "github.com/MicahParks/magiclinksdev/model" - - "github.com/MicahParks/magiclinksdev/network/middleware/ctxkey" -) - -// HandleLinkCreate handles the link creation endpoint. -func (s *Server) HandleLinkCreate(ctx context.Context, req model.ValidLinkCreateRequest) (response model.LinkCreateResponse, err error) { - linkArgs := req.LinkArgs - - magicLinkResp, err := s.createLink(ctx, linkArgs) - if err != nil { - return model.LinkCreateResponse{}, fmt.Errorf("failed to create magic link: %w", err) - } - - resp := model.LinkCreateResponse{ - LinkCreateResults: model.LinkCreateResults{ - MagicLink: magicLinkResp.MagicLink.String(), - Secret: magicLinkResp.Secret, - }, - RequestMetadata: model.RequestMetadata{ - UUID: ctx.Value(ctxkey.RequestUUID).(uuid.UUID), - }, - } - - return resp, nil -} - -func (s *Server) createLink(ctx context.Context, linkArgs model.ValidLinkCreateArgs) (magiclink.CreateResponse, error) { - magicLinkCreateArgs, err := s.createLinkArgs(ctx, linkArgs) - if err != nil { - return magiclink.CreateResponse{}, fmt.Errorf("failed to create magic link create args: %w", err) - } - - magicLinkResp, err := s.MagicLink.NewLink(ctx, magicLinkCreateArgs) - if err != nil { - return magiclink.CreateResponse{}, fmt.Errorf("failed to create magic link: %w", err) - } - - return magicLinkResp, nil -} diff --git a/handle/magic_link_create.go b/handle/magic_link_create.go new file mode 100644 index 0000000..fde26de --- /dev/null +++ b/handle/magic_link_create.go @@ -0,0 +1,48 @@ +package handle + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/MicahParks/magiclinksdev/magiclink" + "github.com/MicahParks/magiclinksdev/model" + + "github.com/MicahParks/magiclinksdev/network/middleware/ctxkey" +) + +func (s *Server) HandleMagicLinkCreate(ctx context.Context, req model.ValidMagicLinkCreateRequest) (response model.MagicLinkCreateResponse, err error) { + linkParams := req.MagicLinkParams + + magicLinkRes, err := s.createLink(ctx, linkParams) + if err != nil { + return model.MagicLinkCreateResponse{}, fmt.Errorf("failed to create magic link: %w", err) + } + + resp := model.MagicLinkCreateResponse{ + MagicLinkCreateResults: model.MagicLinkCreateResults{ + MagicLink: magicLinkRes.MagicLink.String(), + Secret: magicLinkRes.Secret, + }, + RequestMetadata: model.RequestMetadata{ + UUID: ctx.Value(ctxkey.RequestUUID).(uuid.UUID), + }, + } + + return resp, nil +} + +func (s *Server) createLink(ctx context.Context, linkParams model.ValidMagicLinkCreateParams) (magiclink.CreateResponse, error) { + magicLinkCreateParams, err := s.createLinkParams(ctx, linkParams) + if err != nil { + return magiclink.CreateResponse{}, fmt.Errorf("failed to create magic link create args: %w", err) + } + + magicLinkRes, err := s.MagicLink.NewLink(ctx, magicLinkCreateParams) + if err != nil { + return magiclink.CreateResponse{}, fmt.Errorf("failed to create magic link: %w", err) + } + + return magicLinkRes, nil +} diff --git a/handle/magic_link_email_create.go b/handle/magic_link_email_create.go new file mode 100644 index 0000000..56eee48 --- /dev/null +++ b/handle/magic_link_email_create.go @@ -0,0 +1,68 @@ +package handle + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/MicahParks/magiclinksdev/config" + "github.com/MicahParks/magiclinksdev/email" + "github.com/MicahParks/magiclinksdev/model" + "github.com/MicahParks/magiclinksdev/network/middleware/ctxkey" +) + +func (s *Server) HandleMagicLinkEmailCreate(ctx context.Context, req model.ValidMagicLinkEmailCreateRequest) (model.MagicLinkEmailCreateResponse, error) { + emailParams := req.MagicLinkEmailCreateParams + linkParams := req.MagicLinkCreateParams + + magicLinkRes, err := s.createLink(ctx, linkParams) + if err != nil { + return model.MagicLinkEmailCreateResponse{}, fmt.Errorf("failed to create magic link: %w", err) + } + + meta := email.TemplateMetadata{ + HTMLInstruction: fmt.Sprintf("Magic link from %s.", emailParams.ServiceName), + HTMLTitle: fmt.Sprintf("Magic link from %s", emailParams.ServiceName), + MSOButtonStop: email.MSOButtonStop, + MSOButtonStart: email.MSOButtonStart, + MSOHead: email.MSOHead, + } + tData := email.MagicLinkTemplateData{ + ButtonText: emailParams.ButtonText, + Expiration: linkParams.Lifespan.String(), + Greeting: emailParams.Greeting, + LogoImageURL: emailParams.LogoImageURL, + LogoClickURL: emailParams.LogoClickURL, + LogoAltText: "logo", + MagicLink: magicLinkRes.MagicLink.String(), + Meta: meta, + Subtitle: emailParams.SubTitle, + Title: emailParams.Title, + ReCATPTCHA: s.Config.PreventRobots.Method == config.PreventRobotsReCAPTCHAV3, + } + e := email.Email{ + Subject: emailParams.Subject, + TemplateData: tData, + To: emailParams.ToEmail, + } + err = s.EmailProvider.SendMagicLink(ctx, e) + if err != nil { + return model.MagicLinkEmailCreateResponse{}, fmt.Errorf("failed to send email: %w", err) + } + + linkCreateResponse := model.MagicLinkCreateResults{ + MagicLink: magicLinkRes.MagicLink.String(), + Secret: magicLinkRes.Secret, + } + resp := model.MagicLinkEmailCreateResponse{ + MagicLinkEmailCreateResults: model.MagicLinkEmailCreateResults{ + MagicLinkCreateResults: linkCreateResponse, + }, + RequestMetadata: model.RequestMetadata{ + UUID: ctx.Value(ctxkey.RequestUUID).(uuid.UUID), + }, + } + + return resp, nil +} diff --git a/handle/otp_create.go b/handle/otp_create.go new file mode 100644 index 0000000..af8a19c --- /dev/null +++ b/handle/otp_create.go @@ -0,0 +1,45 @@ +package handle + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/MicahParks/magiclinksdev/model" + "github.com/MicahParks/magiclinksdev/network/middleware/ctxkey" + "github.com/MicahParks/magiclinksdev/otp" +) + +func (s *Server) HandleOTPCreate(ctx context.Context, req model.ValidOTPCreateRequest) (response model.OTPCreateResponse, err error) { + otpParams := createOTPParams(req.OTPCreateParams) + + otpRes, err := s.Store.OTPCreate(ctx, otpParams) + if err != nil { + return model.OTPCreateResponse{}, fmt.Errorf("failed to create OTP: %w", err) + } + + resp := model.OTPCreateResponse{ + OTPCreateResults: model.OTPCreateResults{ + ID: otpRes.ID, + OTP: otpRes.OTP, + }, + RequestMetadata: model.RequestMetadata{ + UUID: ctx.Value(ctxkey.RequestUUID).(uuid.UUID), + }, + } + + return resp, nil +} + +func createOTPParams(otpParams model.ValidOTPCreateParams) otp.CreateParams { + params := otp.CreateParams{ + CharSetAlphaLower: otpParams.CharSetAlphaLower, + CharSetAlphaUpper: otpParams.CharSetAlphaUpper, + CharSetNumeric: otpParams.CharSetNumeric, + Expires: time.Now().Add(otpParams.Lifespan), + Length: otpParams.Length, + } + return params +} diff --git a/handle/otp_email_create.go b/handle/otp_email_create.go new file mode 100644 index 0000000..e696e6a --- /dev/null +++ b/handle/otp_email_create.go @@ -0,0 +1,64 @@ +package handle + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/MicahParks/magiclinksdev/email" + "github.com/MicahParks/magiclinksdev/model" + "github.com/MicahParks/magiclinksdev/network/middleware/ctxkey" +) + +func (s *Server) HandleOTPEmailCreate(ctx context.Context, req model.ValidOTPEmailCreateRequest) (response model.OTPEmailCreateResponse, err error) { + emailParams := req.OTPEmailCreateParams + otpParams := createOTPParams(req.OTPCreateParams) + + otpRes, err := s.Store.OTPCreate(ctx, otpParams) + if err != nil { + return model.OTPEmailCreateResponse{}, fmt.Errorf("failed to create OTP: %w", err) + } + + meta := email.TemplateMetadata{ + HTMLInstruction: fmt.Sprintf("One-time password from %s.", emailParams.ServiceName), + HTMLTitle: fmt.Sprintf("One-time password from %s", emailParams.ServiceName), + MSOButtonStop: email.MSOButtonStop, + MSOButtonStart: email.MSOButtonStart, + MSOHead: email.MSOHead, + } + tData := email.OTPTemplateData{ + Expiration: req.OTPCreateParams.Lifespan.String(), + Greeting: emailParams.Greeting, + Meta: meta, + OTP: otpRes.OTP, + Subtitle: emailParams.SubTitle, + Title: emailParams.Title, + LogoImageURL: emailParams.LogoImageURL, + LogoClickURL: emailParams.LogoClickURL, + LogoAltText: "logo", + } + e := email.Email{ + Subject: emailParams.Subject, + TemplateData: tData, + To: emailParams.ToEmail, + } + err = s.EmailProvider.SendOTP(ctx, e) + if err != nil { + return model.OTPEmailCreateResponse{}, fmt.Errorf("failed to send email: %w", err) + } + + resp := model.OTPEmailCreateResponse{ + OTPEmailCreateResults: model.OTPEmailCreateResults{ + OTPCreateResults: model.OTPCreateResults{ + ID: otpRes.ID, + OTP: otpRes.OTP, + }, + }, + RequestMetadata: model.RequestMetadata{ + UUID: ctx.Value(ctxkey.RequestUUID).(uuid.UUID), + }, + } + + return resp, nil +} diff --git a/handle/otp_validate.go b/handle/otp_validate.go new file mode 100644 index 0000000..0dde229 --- /dev/null +++ b/handle/otp_validate.go @@ -0,0 +1,25 @@ +package handle + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/MicahParks/magiclinksdev/model" + "github.com/MicahParks/magiclinksdev/network/middleware/ctxkey" +) + +func (s *Server) HandleOTPValidate(ctx context.Context, req model.ValidOTPValidateRequest) (response model.OTPValidateResponse, err error) { + err = s.Store.OTPValidate(ctx, req.OTPValidateParams.ID, req.OTPValidateParams.OTP) + if err != nil { + return model.OTPValidateResponse{}, fmt.Errorf("failed to validate OTP: %w", err) + } + resp := model.OTPValidateResponse{ + OTPValidateResults: model.OTPValidateResults{}, + RequestMetadata: model.RequestMetadata{ + UUID: ctx.Value(ctxkey.RequestUUID).(uuid.UUID), + }, + } + return resp, nil +} diff --git a/handle/server.go b/handle/server.go index 7fe905f..21b30d6 100644 --- a/handle/server.go +++ b/handle/server.go @@ -51,7 +51,6 @@ type MiddlewareHook interface { // MiddlewareHookFunc is a function that can be used to modify the middleware options. type MiddlewareHookFunc func(options MiddlewareOptions) MiddlewareOptions -// Hook implements the MiddlewareHook interface. func (h MiddlewareHookFunc) Hook(options MiddlewareOptions) MiddlewareOptions { return h(options) } diff --git a/handle/service_account.go b/handle/service_account_create.go similarity index 80% rename from handle/service_account.go rename to handle/service_account_create.go index 3abcf3c..d9d3a5e 100644 --- a/handle/service_account.go +++ b/handle/service_account_create.go @@ -12,20 +12,20 @@ import ( // HandleServiceAccountCreate handles the service account creation endpoint. func (s *Server) HandleServiceAccountCreate(ctx context.Context, args model.ValidServiceAccountCreateRequest) (model.ServiceAccountCreateResponse, error) { - saArgs := args.CreateServiceAccountArgs + saParams := args.ServiceAccountCreateParams - createdSA, err := s.Store.CreateSA(ctx, saArgs) + createdSA, err := s.Store.SACreate(ctx, saParams) if err != nil { return model.ServiceAccountCreateResponse{}, fmt.Errorf("failed to create service account: %w", err) } - serviceAccount, err := s.Store.ReadSA(ctx, createdSA.UUID) + serviceAccount, err := s.Store.SARead(ctx, createdSA.UUID) if err != nil { return model.ServiceAccountCreateResponse{}, fmt.Errorf("failed to get service account as marshallable data structure: %w", err) } resp := model.ServiceAccountCreateResponse{ - CreateServiceAccountResults: model.ServiceAccountCreateResults{ + ServiceAccountCreateResults: model.ServiceAccountCreateResults{ ServiceAccount: serviceAccount, }, RequestMetadata: model.RequestMetadata{ diff --git a/magic_link_test.go b/magic_link_test.go index b2781e6..291eb1e 100644 --- a/magic_link_test.go +++ b/magic_link_test.go @@ -29,16 +29,14 @@ type testClaims struct { jwt.RegisteredClaims } -type testCase struct { - name string - keyfunc jwt.Keyfunc - reqBody model.LinkCreateRequest -} - func TestMagicLink(t *testing.T) { const customRedirectQueryKey = "customRedirectQueryKey" - for _, tc := range []testCase{ + for _, tc := range []struct { + name string + keyfunc jwt.Keyfunc + reqBody model.MagicLinkCreateRequest + }{ { name: "Default signing key", keyfunc: func(token *jwt.Token) (any, error) { @@ -48,9 +46,10 @@ func TestMagicLink(t *testing.T) { if err != nil { panic(fmt.Sprintf("failed to begin transaction: %v", err)) } + //goland:noinspection GoUnhandledErrorResult defer tx.Rollback(ctx) ctx = context.WithValue(ctx, ctxkey.Tx, tx) - defaultKey, err := server.Store.ReadDefaultSigningKey(ctx) + defaultKey, err := server.Store.SigningKeyDefaultRead(ctx) if err != nil { panic(fmt.Sprintf("failed to read default signing key: %v", err)) } @@ -64,13 +63,13 @@ func TestMagicLink(t *testing.T) { } return ed.Public(), nil }, - reqBody: model.LinkCreateRequest{ - LinkArgs: model.LinkCreateArgs{ - JWTCreateArgs: model.JWTCreateArgs{ - JWTClaims: map[string]string{"foo": "bar"}, - JWTLifespanSeconds: 0, + reqBody: model.MagicLinkCreateRequest{ + MagicLinkCreateParams: model.MagicLinkCreateParams{ + JWTCreateParams: model.JWTCreateParams{ + Claims: map[string]string{"foo": "bar"}, + LifespanSeconds: 0, }, - LinkLifespan: 0, + LifespanSeconds: 0, RedirectQueryKey: customRedirectQueryKey, RedirectURL: "https://github.com/MicahParks/magiclinksdev", }, @@ -90,14 +89,14 @@ func TestMagicLink(t *testing.T) { } panic("no RSA signing key") }, - reqBody: model.LinkCreateRequest{ - LinkArgs: model.LinkCreateArgs{ - JWTCreateArgs: model.JWTCreateArgs{ - JWTAlg: jwkset.AlgRS256.String(), - JWTClaims: map[string]string{"foo": "bar"}, - JWTLifespanSeconds: 0, + reqBody: model.MagicLinkCreateRequest{ + MagicLinkCreateParams: model.MagicLinkCreateParams{ + JWTCreateParams: model.JWTCreateParams{ + Alg: jwkset.AlgRS256.String(), + Claims: map[string]string{"foo": "bar"}, + LifespanSeconds: 0, }, - LinkLifespan: 0, + LifespanSeconds: 0, RedirectQueryKey: customRedirectQueryKey, RedirectURL: "https://github.com/MicahParks/magiclinksdev", }, @@ -111,7 +110,7 @@ func TestMagicLink(t *testing.T) { } recorder := httptest.NewRecorder() - u, err := assets.conf.Server.BaseURL.Get().Parse(network.PathLinkCreate) + u, err := assets.conf.Server.BaseURL.Get().Parse(network.PathMagicLinkCreate) if err != nil { t.Fatalf("Failed to parse URL: %v", err) } @@ -127,14 +126,14 @@ func TestMagicLink(t *testing.T) { t.Fatalf("Received non-JSON content type: %s", recorder.Header().Get(mld.HeaderContentType)) } - var linkCreateResponse model.LinkCreateResponse + var linkCreateResponse model.MagicLinkCreateResponse err = json.Unmarshal(recorder.Body.Bytes(), &linkCreateResponse) if err != nil { t.Fatalf("Failed to unmarshal response body: %v", err) } recorder = httptest.NewRecorder() - req = httptest.NewRequest(http.MethodGet, linkCreateResponse.LinkCreateResults.MagicLink, nil) + req = httptest.NewRequest(http.MethodGet, linkCreateResponse.MagicLinkCreateResults.MagicLink, nil) reqSent := time.Now() assets.mux.ServeHTTP(recorder, req) @@ -180,12 +179,12 @@ func TestMagicLink(t *testing.T) { } redirectURL.RawQuery = "" - if redirectURL.String() != tc.reqBody.LinkArgs.RedirectURL { - t.Fatalf("Expected redirect URL %q, got %q", tc.reqBody.LinkArgs.RedirectURL, redirectURL.String()) + if redirectURL.String() != tc.reqBody.MagicLinkCreateParams.RedirectURL { + t.Fatalf("Expected redirect URL %q, got %q", tc.reqBody.MagicLinkCreateParams.RedirectURL, redirectURL.String()) } recorder = httptest.NewRecorder() - req = httptest.NewRequest(http.MethodGet, linkCreateResponse.LinkCreateResults.MagicLink, nil) + req = httptest.NewRequest(http.MethodGet, linkCreateResponse.MagicLinkCreateResults.MagicLink, nil) assets.mux.ServeHTTP(recorder, req) if recorder.Code != http.StatusNotFound { diff --git a/magiclink/frontend/default.css b/magiclink/frontend/default.css index b93d96a..2746a1e 100644 --- a/magiclink/frontend/default.css +++ b/magiclink/frontend/default.css @@ -1 +1 @@ -/*! tailwindcss v3.3.1 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.mx-auto{margin-left:auto;margin-right:auto}.mt-10{margin-top:2.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-auto{margin-top:auto}.mt-2{margin-top:.5rem}.flex{display:flex}.h-full{height:100%}.min-h-full{min-height:100%}.max-w-7xl{max-width:80rem}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-center{justify-content:center}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.rounded-md{border-radius:.375rem}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.pt-24{padding-top:6rem}.text-center{text-align:center}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-base{font-size:1rem;line-height:1.5rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.leading-5{line-height:1.25rem}.leading-7{line-height:1.75rem}.tracking-tight{letter-spacing:-.025em}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.shadow-sm{--tw-shadow:0 1px 2px 0 #0000000d;--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:bg-indigo-500:hover{--tw-bg-opacity:1;background-color:rgb(99 102 241/var(--tw-bg-opacity))}.focus-visible\:outline:focus-visible{outline-style:solid}.focus-visible\:outline-2:focus-visible{outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-indigo-600:focus-visible{outline-color:#4f46e5}@media (min-width:640px){.sm\:pt-32{padding-top:8rem}.sm\:text-5xl{font-size:3rem;line-height:1}}@media (min-width:1024px){.lg\:px-8{padding-left:2rem;padding-right:2rem}} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.13 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}.mx-auto{margin-left:auto;margin-right:auto}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-auto{margin-top:auto}.flex{display:flex}.hidden{display:none}.h-full{height:100%}.min-h-full{min-height:100%}.max-w-7xl{max-width:80rem}.max-w-5xl{max-width:64rem}.max-w-2xl{max-width:42rem}.max-w-xl{max-width:36rem}.max-w-fit{max-width:-moz-fit-content;max-width:fit-content}.list-disc{list-style-type:disc}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-center{justify-content:center}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.rounded-md{border-radius:.375rem}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.px-4{padding-left:1rem;padding-right:1rem}.pt-24{padding-top:6rem}.text-left{text-align:left}.text-center{text-align:center}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-base{font-size:1rem;line-height:1.5rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.leading-5{line-height:1.25rem}.leading-7{line-height:1.75rem}.tracking-tight{letter-spacing:-.025em}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:bg-indigo-500:hover{--tw-bg-opacity:1;background-color:rgb(99 102 241/var(--tw-bg-opacity))}.focus-visible\:outline:focus-visible{outline-style:solid}.focus-visible\:outline-2:focus-visible{outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-indigo-600:focus-visible{outline-color:#4f46e5}@media (min-width:640px){.sm\:pt-32{padding-top:8rem}.sm\:text-5xl{font-size:3rem;line-height:1}}@media (min-width:1024px){.lg\:px-8{padding-left:2rem;padding-right:2rem}} \ No newline at end of file diff --git a/magiclink/frontend/recaptchav3.gohtml b/magiclink/frontend/recaptchav3.gohtml index 34ec698..98ffa58 100644 --- a/magiclink/frontend/recaptchav3.gohtml +++ b/magiclink/frontend/recaptchav3.gohtml @@ -9,26 +9,41 @@
-
-

+

+

{{.Code}}

-

+

{{.Title}}

-

+

{{.Instruction}}

-

- If the page does not redirect, the magic link may have expired. -

+ {{- if .ButtonBypass -}} -
- -
+
+

+ Alternatively, try the button below. +

+
+ +
+
{{- end -}}