all repos

onasty @ cbf53ca94f9603fc7aa5306a59e588459a78d634

a one-time notes service
14 files changed, 189 insertions(+), 46 deletions(-)
feat(web): add oauth login (#213)

* refactor(api): use frontendURL instead of domain

* feat(api): return auth tokens as url fragments in oauth flow

* docs(api): oauth flow

* web: handle oauth callbacks

* web: add oauth login

* fix: set cookies on api side
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed by: GitHub noreply@github.com
Committed at: 2025-09-18 14:12:53 +0300
Parent: 3f33118
A api/.redocly.lint-ignore.yaml

@@ -0,0 +1,7 @@

+paths/auth/oauth-provider-callback.yml: + operation-2xx-response: + - '#/get/responses' + +paths/auth/oauth-provider.yml: + operation-2xx-response: + - '#/get/responses'
M api/paths/auth/oauth-provider-callback.yml

@@ -28,22 +28,14 @@ schema:

type: string responses: - '200': - description: OAuth login successful - content: - application/json: + '302': + description: Redirect to frontend with tokens + headers: + Location: + description: Frontend URL with tokens or error as query params schema: - $ref: '../../components/schemas/JwtTokens.yml' - - # TODO: unimplemented - # '302': - # description: Redirect to frontend with tokens (alternative flow) - # headers: - # Location: - # description: Frontend URL with tokens as query params or hash - # schema: - # type: string - # example: "onasty.local/api/v1/auth/success?access=...&refresh=..." + type: string + example: "onasty.local/api/v1/auth/callback?access_token=...&refresh_token=...&error=..." '400': $ref: '../../components/responses/ErrorResponse.yml'
M api/paths/auth/oauth-provider.yml

@@ -13,8 +13,6 @@ type: string

enum: [google, github] responses: - '200': - description: CANNOT BE RETURNED it's here just to make redocly linter not angry at this code '302': description: Redirect to OAuth provider '400':
M cmd/api/main.go

@@ -149,6 +149,7 @@ usersrv,

notesrv, cfg.AppEnv, cfg.AppURL, + cfg.FrontendURL, cfg.CORSAllowedOrigins, cfg.CORSMaxAge, rateLimiterConfig,
M e2e/e2e_test.go

@@ -155,6 +155,7 @@ usersrv,

notesrv, cfg.AppEnv, cfg.AppURL, + cfg.FrontendURL, cfg.CORSAllowedOrigins, cfg.CORSMaxAge, ratelimitCfg,
M internal/config/config.go

@@ -22,9 +22,10 @@ return e == "debug" || e == "test"

} type Config struct { - AppEnv Environment - AppURL string - NatsURL string + AppEnv Environment + AppURL string + FrontendURL string + NatsURL string CORSAllowedOrigins []string CORSMaxAge time.Duration

@@ -79,9 +80,10 @@

func NewConfig() *Config { once.Do(func() { instance = &Config{ - AppEnv: Environment(getenvOrDefault("APP_ENV", "debug")), - AppURL: getenvOrDefault("APP_URL", ""), - NatsURL: getenvOrDefault("NATS_URL", ""), + AppEnv: Environment(getenvOrDefault("APP_ENV", "debug")), + AppURL: getenvOrDefault("APP_URL", ""), + FrontendURL: getenvOrDefault("FRONTEND_URL", ""), + NatsURL: getenvOrDefault("NATS_URL", ""), CORSAllowedOrigins: strings.Split(getenvOrDefault("CORS_ALLOWED_ORIGINS", "*"), ","), CORSMaxAge: mustParseDuration(getenvOrDefault("CORS_MAX_AGE", "12h")),
M internal/transport/http/apiv1/apiv1.go

@@ -10,12 +10,15 @@ "github.com/olexsmir/onasty/internal/transport/http/ratelimit"

) type APIV1 struct { - authsrv authsrv.AuthServicer - usersrv usersrv.UserServicer - notesrv notesrv.NoteServicer + authsrv authsrv.AuthServicer + usersrv usersrv.UserServicer + notesrv notesrv.NoteServicer + + env config.Environment slowRatelimitCfg ratelimit.Config - env config.Environment - domain string + + appURL string + frontendURL string } func NewAPIV1(

@@ -24,7 +27,8 @@ us usersrv.UserServicer,

ns notesrv.NoteServicer, slowRatelimitCfg ratelimit.Config, env config.Environment, - domain string, + appURL string, + frontendURL string, ) *APIV1 { return &APIV1{ authsrv: as,

@@ -32,7 +36,8 @@ usersrv: us,

notesrv: ns, slowRatelimitCfg: slowRatelimitCfg, env: env, - domain: domain, + appURL: appURL, + frontendURL: frontendURL, } }
M internal/transport/http/apiv1/auth.go

@@ -2,6 +2,7 @@ package apiv1

import ( "net/http" + "net/url" "time" "github.com/gin-gonic/gin"

@@ -134,7 +135,7 @@ oatuhStateCookie,

redirectInfo.State, int(time.Minute.Seconds()), "/", - a.domain, + a.appURL, !a.env.IsDevMode(), true, )

@@ -143,10 +144,15 @@ c.Redirect(http.StatusSeeOther, redirectInfo.URL)

} func (a *APIV1) oauthCallbackHandler(c *gin.Context) { - state := c.Query("state") + redURL, err := url.Parse(a.frontendURL + "/oauth/callback") + if err != nil { + errorResponse(c, err) + return + } + storedState, err := c.Cookie(oatuhStateCookie) - if err != nil || state != storedState { - newError(c, http.StatusBadRequest, "invalid oauth state") + if err != nil || c.Query("state") != storedState { + a.oauthCallbackErrorResponse(c, redURL) return }

@@ -156,12 +162,19 @@ c.Param("provider"),

c.Query("code"), ) if err != nil { - errorResponse(c, err) + a.oauthCallbackErrorResponse(c, redURL) return } - c.JSON(http.StatusOK, signInResponse{ - AccessToken: tokens.Access, - RefreshToken: tokens.Refresh, - }) + redURL.RawQuery = url.Values{ + "access_token": {tokens.Access}, + "refresh_token": {tokens.Refresh}, + }.Encode() + + c.Redirect(http.StatusFound, redURL.String()) +} + +func (a *APIV1) oauthCallbackErrorResponse(c *gin.Context, u *url.URL) { + u.RawQuery = url.Values{"error": {"internal server error"}}.Encode() + c.Redirect(http.StatusFound, u.String()) }
M internal/transport/http/http.go

@@ -19,8 +19,9 @@ authsrv authsrv.AuthServicer

usersrv usersrv.UserServicer notesrv notesrv.NoteServicer - env config.Environment - domain string + env config.Environment + appURL string + frontendURL string corsAllowedOrigins []string corsMaxAge time.Duration

@@ -33,7 +34,7 @@ as authsrv.AuthServicer,

us usersrv.UserServicer, ns notesrv.NoteServicer, env config.Environment, - domain string, + appURL, frontendURL string, corsAllowedOrigins []string, corsMaxAge time.Duration, ratelimitCfg ratelimit.Config,

@@ -44,7 +45,8 @@ authsrv: as,

usersrv: us, notesrv: ns, env: env, - domain: domain, + appURL: appURL, + frontendURL: frontendURL, corsAllowedOrigins: corsAllowedOrigins, corsMaxAge: corsMaxAge, ratelimitCfg: ratelimitCfg,

@@ -66,7 +68,15 @@ api := r.Group("/api")

{ api.GET("/ping", t.pingHandler) apiv1. - NewAPIV1(t.authsrv, t.usersrv, t.notesrv, t.slowRatelimitCfg, t.env, t.domain). + NewAPIV1( + t.authsrv, + t.usersrv, + t.notesrv, + t.slowRatelimitCfg, + t.env, + t.appURL, + t.frontendURL, + ). Routes(api.Group("/v1")) }
M web/src/Components/Form.elm

@@ -1,4 +1,4 @@

-module Components.Form exposing (ButtonStyle(..), CanBeClicked, InputStyle(..), button, input, submitButton) +module Components.Form exposing (ButtonStyle(..), CanBeClicked, InputStyle(..), button, input, oauthButton, submitButton) import Html as H exposing (Html) import Html.Attributes as A

@@ -90,6 +90,7 @@ | PrimaryReverse CanBeClicked

| Secondary CanBeClicked | SecondaryDisabled CanBeClicked | SecondaryDanger + | OauthButton CanBeClicked button : { text : String, disabled : Bool, onClick : msg, style : ButtonStyle } -> Html msg

@@ -113,6 +114,21 @@ ]

[ H.text opts.text ] +oauthButton : { text : String, disabled : Bool, onClick : msg, iconURL : String } -> Html msg +oauthButton { text, disabled, onClick, iconURL } = + H.button + [ A.type_ "button" + , A.class (buttonStyleToClass (OauthButton (not disabled)) "mt-2") + , A.disabled disabled + , E.onClick onClick + ] + [ H.div [ A.class "flex" ] + [ H.img [ A.class "w-5 h-5 mr-3", A.src iconURL ] [] + , H.text text + ] + ] + + buttonStyleToClass : ButtonStyle -> String -> String buttonStyleToClass style appendClasses = case style of

@@ -142,6 +158,12 @@ getButtonClasses canBeClicked

appendClasses "w-full px-4 py-2 rounded-md focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors mt-3 border border-gray-300 text-gray-400 cursor-not-allowed" "w-full px-4 py-2 rounded-md focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 transition-colors mt-3 border border-gray-300 text-gray-700 hover:bg-gray-50" + + OauthButton canBeClicked -> + getButtonClasses canBeClicked + appendClasses + "w-full flex items-center justify-center gap-3 px-4 py-2 rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 bg-white hover:bg-gray-50 border border-gray-300 text-gray-700" + "w-full flex items-center justify-center gap-3 px-4 py-2 rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 bg-white hover:bg-gray-50 border border-gray-300 text-gray-700 opacity-50 cursor-not-allowed" getButtonClasses : Bool -> String -> String -> String -> String
M web/src/Pages/Auth.elm

@@ -88,11 +88,17 @@ | UserUpdatedInput Field String

| UserChangedFormVariant FormVariant | UserClickedSubmit | UserClickedResendActivationEmail + | UserClickedOAuth Provider | ApiSignInResponded (Result Api.Error Credentials) | ApiSignUpResponded (Result Api.Error ()) | ApiForgotPasswordResponded (Result Api.Error ()) | ApiSetNewPasswordResponded (Result Api.Error ()) | ApiResendVerificationEmail (Result Api.Error ()) + + +type Provider + = Github + | Google type Field

@@ -149,6 +155,14 @@ SetNewPassword token ->

Api.Auth.resetPassword { onResponse = ApiSetNewPasswordResponded, token = token, password = model.password } ) + UserClickedOAuth provider -> + case provider of + Github -> + ( model, Effect.loadExternalUrl "/api/v1/oauth/github" ) + + Google -> + ( model, Effect.loadExternalUrl "/api/v1/oauth/google" ) + UserClickedResendActivationEmail -> ( { model | lastClicked = model.now } , Api.Auth.resendVerificationEmail

@@ -223,11 +237,11 @@ { title = "Authentication"

, body = [ H.div [ A.class "min-h-screen flex items-center justify-center bg-gray-50 p-4" ] [ H.div [ A.class "w-full max-w-md bg-white rounded-lg border border-gray-200 shadow-sm" ] - -- TODO: add oauth buttons [ viewBanner model , viewBoxHeader model.formVariant , H.div [ A.class "px-6 pb-6 space-y-4" ] [ viewChangeVariant model.formVariant + , viewOauthButtons model.isSubmittingForm , H.div [ A.class "border-t border-gray-200" ] [] , viewForm model ]

@@ -325,6 +339,24 @@ { text = "Sign Up"

, disabled = variant == SignUp , style = Components.Form.Primary (variant == SignUp) , onClick = UserChangedFormVariant SignUp + } + ] + + +viewOauthButtons : Bool -> Html Msg +viewOauthButtons isSubmittingForm = + H.div [] + [ Components.Form.oauthButton + { text = "Sign in with Google" + , onClick = UserClickedOAuth Google + , disabled = isSubmittingForm + , iconURL = "/static/google.svg" + } + , Components.Form.oauthButton + { text = "Sign in with GitHub" + , onClick = UserClickedOAuth Github + , disabled = isSubmittingForm + , iconURL = "/static/github.svg" } ]
A web/src/Pages/Oauth/Callback.elm

@@ -0,0 +1,58 @@

+module Pages.Oauth.Callback exposing (Model, Msg, page) + +import Components.Box +import Components.Utils +import Dict exposing (Dict) +import Effect exposing (Effect) +import Layouts +import Page exposing (Page) +import Route exposing (Route) +import Shared +import View exposing (View) + + +type alias Msg = + {} + + +page : Shared.Model -> Route () -> Page Model Msg +page _ route = + Page.new + { init = init route.query + , update = \_ m -> ( m, Effect.none ) + , subscriptions = \_ -> Sub.none + , view = view + } + |> Page.withLayout (\_ -> Layouts.Header {}) + + +type alias Model = + { error : String } + + +init : Dict String String -> () -> ( Model, Effect Msg ) +init query () = + case + ( Dict.get "access_token" query + , Dict.get "refresh_token" query + , Dict.get "error" query + ) + of + ( Just at, Just rt, _ ) -> + ( { error = "" }, Effect.signin { accessToken = at, refreshToken = rt } ) + + ( _, _, Just error ) -> + ( { error = error }, Effect.none ) + + _ -> + ( { error = "Invalid server response" }, Effect.none ) + + +view : Model -> View msg +view model = + { title = "Oauth" + , body = + [ Components.Utils.commonContainer + [ Components.Box.error model.error ] + ] + }
A web/static/github.svg

@@ -0,0 +1,1 @@

+<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>
A web/static/google.svg

@@ -0,0 +1,1 @@

+<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/><path d="M1 1h22v22H1z" fill="none"/></svg>