Skip to content

Repository files navigation

authflow

Reusable Go authentication orchestration for projects that want first-party login without tying the core package to a database, mail vendor, OAuth SDK, or web framework.

authflow currently provides:

  • Email OTP start and verify flow.
  • Verified external identity login for providers such as Google.
  • Opaque session issuing with caller-owned persistence.
  • Store interfaces for users, identities, OTP challenges, and sessions.
  • Extension points for custom primary methods and MFA or step-up policies.
  • Optional adapters for Google ID-token verification and Resend OTP delivery.

The module is Apache-2.0 licensed.

Install

go get github.com/aatuh/authflow

Optional adapters live in separate modules so core-only consumers do not pull in Google or email-provider dependency trees:

go get github.com/aatuh/authflow/adapters/googleidtoken
go get github.com/aatuh/authflow/adapters/resendotp

Core Wiring

Your application owns persistence and transport. Implement these interfaces in your project:

  • authflow.UserStore
  • authflow.OTPChallengeStore
  • authflow.SessionStore
  • authflow.OTPSender

Then create a service:

service, err := authflow.NewService(users, otps, sessions, sender, authflow.Config{
    OTPSecret:            os.Getenv("AUTH_OTP_SECRET"),
    SessionTTL:           30 * 24 * time.Hour,
    AccountLinkingPolicy: authflow.TrustedProviderLinkingPolicy{
        authflow.ProviderEmailOTP: true,
        authflow.ProviderGoogle:   true, // opt in only after verifying provider trust.
    },
})

The returned access token is opaque. Store only authflow.HashSessionToken(token) in your database and put the raw token in your application session mechanism, for example an HTTP-only cookie owned by a server-rendered frontend.

Email OTP

challenge, err := service.StartEmailOTP(ctx, "user@example.com")
if err != nil {
    return err
}

result, err := service.VerifyEmailOTP(ctx, challenge.ID, submittedCode)
if err != nil {
    return err
}

StartEmailOTP stores a hashed OTP challenge and calls your OTPSender. VerifyEmailOTP calls your OTPChallengeStore.VerifyOTPChallenge method. That store method must atomically validate expiry, consumption, and attempt limits, increment failed attempts before returning ErrInvalidOTP, and mark a valid challenge consumed before returning success. This compare-and-consume contract is what prevents concurrent valid OTP replay from issuing multiple sessions.

See examples/email-otp for a Resend-backed wiring sketch.

Google Login

The core package does not import Google SDKs. Use the adapter or implement authflow.ExternalIdentityVerifier yourself:

verifier := googleidtoken.New(os.Getenv("GOOGLE_CLIENT_ID"))
result, err := service.LoginWithExternalCredential(ctx, verifier, idToken)

The frontend should obtain the ID token with Google Identity Services. The backend verifies the ID token and then calls the core login flow with the verified identity.

By default, authflow only links an existing user by email for first-party email OTP identities. To attach Google or another provider to an existing account by email, configure AccountLinkingPolicy explicitly. Denied links return authflow.ErrAccountLinkingDenied. Existing provider-subject identities still log in without consulting the email-linking policy.

See examples/google-login for a minimal wiring sketch.

Custom Primary Methods

Use authflow.PrimaryMethod for login methods that are not built into the service:

method := authflow.PrimaryMethodFunc(func(ctx context.Context, credential authflow.Credential) (authflow.Identity, []authflow.Factor, error) {
    identity, err := verifyMagicLink(ctx, credential.Value)
    if err != nil {
        return authflow.Identity{}, nil, err
    }
    return identity, []authflow.Factor{"magic_link"}, nil
})

result, err := service.LoginWithMethod(ctx, method, authflow.Credential{Value: token})

The method returns the verified identity plus completed factors. The service still handles user linking, step-up policy evaluation, and session issuance.

MFA And Step-Up

Configure authflow.StepUpPolicy to require extra factors before a session is issued:

service, err := authflow.NewService(users, otps, sessions, sender, authflow.Config{
    OTPSecret:           os.Getenv("AUTH_OTP_SECRET"),
    StepUpPolicy:        authflow.StaticPolicy{authflow.FactorEmailOTP, "totp"},
    StepUpContinuations: stepUps,
})

If a login is valid but lacks required factors, the service returns authflow.StepUpRequiredError with an opaque continuation token. Your application verifies the extra factor with its own implementation, then finishes with that token:

result, err := service.CompleteStepUp(ctx, stepUp.ContinuationToken, []authflow.Factor{"totp"})

Configure Config.StepUpContinuations when a policy can require step-up. The continuation store must save the server-owned user, identity, completed factors, required factors, and expiry, and ConsumeStepUpContinuation must atomically reject expired, unknown, or already-consumed tokens.

The contract tests include a fake second factor to prove policy-required MFA can block session issuance until the additional factor is completed.

Store And Security Responsibilities

Applications own persistence and HTTP/session transport. Store implementations must provide these guarantees:

  • OTP verification is atomic and does not fail open if failed-attempt persistence fails.
  • OTP and step-up continuation cleanup removes expired records on a bounded schedule.
  • Session storage keeps only HashSessionToken(token), supports validation and revocation in the application, and never logs raw bearer tokens.
  • Step-up continuations are opaque to clients, single-use, and bound to the stored user, identity, completed factors, required factors, and expiry.
  • Account linking by email is an explicit product decision. Trust only providers whose email verification semantics are acceptable for your application.
  • Product profile data belongs in application-owned user/profile tables. authflow.User intentionally contains only authentication-generic fields.

For browser apps, keep the raw opaque token in an HTTP-only, secure cookie owned by your server layer. Tests should use fake senders and fake identity verifiers; they should never call Resend, Google, or other live providers.

Auth Event Hooks

Set Config.EventHook to observe safe auth events:

cfg.EventHook = authflow.AuthEventHookFunc(func(ctx context.Context, event authflow.AuthAuditEvent) {
    auditLog(event.Type, event.UserID, event.Provider, event.Reason)
})

Events cover OTP started, OTP failed, OTP verified, identity linked, session issued, step-up required, and step-up completed. Event payloads exclude raw OTP codes, credentials, and session tokens.

Breaking Changes Since v0.1.0

  • OTPChallengeStore now requires atomic VerifyOTPChallenge semantics instead of separate get, consume, and attempt-update calls.
  • CompleteStepUp now accepts a continuation token and completed factors; it no longer accepts caller-provided User or Identity state.
  • Step-up policies that can block login require Config.StepUpContinuations.
  • Email-based account linking is denied unless AccountLinkingPolicy allows the provider.
  • authflow.User no longer contains TilaFix-specific profile fields.
  • Google and Resend adapters are separate Go modules under adapters/*.

Testing

make finalize

CI runs gofmt, go vet, and go test ./....

About

Reusable Go authentication orchestration for email OTP, external identities, and step-up flows

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages