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.
go get github.com/aatuh/authflowOptional 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/resendotpYour application owns persistence and transport. Implement these interfaces in your project:
authflow.UserStoreauthflow.OTPChallengeStoreauthflow.SessionStoreauthflow.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.
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.
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.
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.
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.
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.Userintentionally 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.
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.
OTPChallengeStorenow requires atomicVerifyOTPChallengesemantics instead of separate get, consume, and attempt-update calls.CompleteStepUpnow accepts a continuation token and completed factors; it no longer accepts caller-providedUserorIdentitystate.- Step-up policies that can block login require
Config.StepUpContinuations. - Email-based account linking is denied unless
AccountLinkingPolicyallows the provider. authflow.Userno longer contains TilaFix-specific profile fields.- Google and Resend adapters are separate Go modules under
adapters/*.
make finalizeCI runs gofmt, go vet, and go test ./....