Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 11 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ This project uses [devbox](https://www.jetify.com/devbox) and [direnv](https://d
Install both for the smoothest experience.

Copy `.envrc.sample` to `.envrc` and set environment variables appropriately.
Then run `direnv allow` so it will be evaluated when you enter this directory.

You can run the server with `devbox services up`

Expand Down Expand Up @@ -71,23 +72,17 @@ Goals:

Auth flow:

1. CLI posts email address to the server (IN PROGRESS)
1. [X] server finds or creates row in `user` table
2. [X] server creates new row in `session` table with:
- `email_validation_token` for unique url for user to validate their email address and get a `validation_code`
- `fetch_session_token` for unique url for cli to fetch session (along with `validation_code`)
4. [ ] server emails validation link with `email_validation_token` (postmark)
5. [ ] server returns fetch session link with `fetch_session_token`
2. User follows email validation link to get a validation code.
3. User enters validation code on the CLI, where it was prompting/waiting for it.
4. CLI posts to the fetch link with the validation code.
5. Server creates and returns a session token.
6. CLI saves token on disk to use for authenticated requests.
1. [X] CLI posts email address to the server. Server emails confirmation link.
2. [X] User follows email confirmation link to get a confirmation code.
3. [ ] User enters confirmation code on the CLI, where it was prompting/waiting for it.
4. [ ] CLI posts to the fetch link with the confirmation code.
5. [ ] Server creates and returns a session token.
6. [ ] CLI saves token on disk to use for authenticated requests.

Benefits:

- Not saving or requiring passwords.
- CLI auth tightly coupled to email validation.
- Can't get session token without fetch token/link from the cli initiation and the validation code.
- Can't get the validation code without validation token/link from the email.
- CLI auth loosely coupled to where you check your email (clicking validation link in email gives you a code that can be manually entered on the cli)
- CLI auth tightly coupled to email confirmation.
- Can't get session token without fetch token/link from the cli initiation and the confirmation code.
- Can't get the confirmation code without confirmation token/link from the email.
- CLI auth loosely coupled to where you check your email (clicking confirmation link in email gives you a code that can be manually entered on the cli)
8 changes: 4 additions & 4 deletions gren.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@
"blaix/gren-ws4sql": "3.0.2",
"gren-lang/core": "7.0.0",
"gren-lang/node": "6.0.0",
"gren-lang/test": "5.0.0"
"gren-lang/test": "5.0.0",
"gren-lang/url": "6.0.0"
},
"indirect": {
"gren-lang/test-runner-node": "7.0.0",
"gren-lang/url": "6.0.0"
"gren-lang/test-runner-node": "7.0.0"
}
}
}
}
24 changes: 24 additions & 0 deletions src/Main.gren
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import Route.Session
import Stream
import Task exposing (Task)
import Test.E2E.Helper as Helper
import Url.Parser
import Url.Parser.Query as Query


main : Program Model Msg
Expand Down Expand Up @@ -207,6 +209,28 @@ route model request response =
Route.Error.invalidRequestData response
"Request json did not contain a valid `email` field."

{ method = GET, path = [ "session", "confirm-email" ] } ->
let
queryParser =
Query.string "token"
|> Url.Parser.query

-- https://github.com/elm/url/issues/17
queryOnly =
{ request.url | path = "" }
in
when Url.Parser.parse queryParser queryOnly is
Just (Just token) ->
Route.Session.confirmEmail
{ db = model.db
, requestData = { token = token }
, response = response
}

_ ->
Route.Error.invalidRequestData response
"Request must contain a `token`"

_ ->
Route.Error.notFound response

Expand Down
2 changes: 0 additions & 2 deletions src/Postmark.gren
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ url =
"https://api.postmarkapp.com/email"


-- Might not want to always hardcode this. But for now we're only using this for
-- email validation.
from : String
from =
"no-reply@gren-lang.org"
Expand Down
10 changes: 5 additions & 5 deletions src/Registry/Db.gren
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,17 @@ migrate db =
created INTEGER NOT NULL,
user_id INTEGER NOT NULL REFERENCES user(id),

-- Token used in email validation link.
email_validation_token TEXT NOT NULL UNIQUE,
-- Token used in email confirmation link.
email_confirmation_token TEXT NOT NULL UNIQUE,

-- Create and show this when user follows email validation link.
-- Create and show this when user follows email confirmation link.
-- User will provide this to the CLI.
cli_validation_token TEXT UNIQUE,
confirmation_code TEXT UNIQUE,

-- Token used by the CLI to fetch session token.
fetch_session_token TEXT NOT NULL UNIQUE,

-- Create and return this when CLI posts with validation_code and fetch_session_token
-- Create and return this when CLI posts with confirmation_code and fetch_session_token
session_token TEXT UNIQUE
) STRICT
"""
Expand Down
88 changes: 84 additions & 4 deletions src/Route/Session.gren
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
module Route.Session exposing
( create
, confirmEmail
, confirmEmailPath
)


Expand All @@ -13,8 +15,11 @@ import HttpServer.Response as Response exposing (Response)
import Postmark
import Json.Decode
import Json.Encode
import Route.Error
import Session exposing (Session)
import Task exposing (Task)
import Time
import Url.Builder
import User exposing (User)


Expand All @@ -23,6 +28,20 @@ type Error
| SendEmailFailed HttpClient.Error


-- PATHS


confirmEmailPath : { token : Maybe String } -> String
confirmEmailPath { token } =
let
query =
when token is
Just t -> [ Url.Builder.string "token" t ]
Nothing -> []
in
Url.Builder.absolute [ "session", "confirm-email" ] query


-- ENDPOINTS


Expand All @@ -37,11 +56,72 @@ create :
create { db, secureContext, postmark, requestData, response } =
findOrCreateUser db requestData.email
|> Task.andThen (createSession db secureContext )
|> Task.andThen (sendEmailValidationLink postmark)
|> Task.andThen (sendEmailConfirmationLink postmark)
|> Task.map (createSuccess response)
|> Task.onError (createFailed response)


confirmEmail :
{ db : Db.Connection
, requestData : { token : String }
, response : Response
}
-> Task Never Response
confirmEmail { db, requestData, response } =
let
generateConfirmationCode session =
Time.now
|> Task.andThen (\now ->
if Session.emailConfirmationExpired now session then
Task.fail "Email confirmation link has expired"
else
when session.confirmationCode is
Just code ->
Task.succeed code

Nothing ->
Session.setConfirmationCode db session
|> Task.mapError (\_ -> "Failed to set confirmation code")
|> Task.andThen (\updatedSession ->
when updatedSession.confirmationCode is
Just code ->
Task.succeed code

Nothing ->
Task.fail "Failed to generate confirmation code"
)
)

handleDbError error =
when error is
Db.NoResultError ->
Task.fail "Invalid token"

_ ->
Task.fail "Database error"
in
Session.getWithEmailConfirmationToken db requestData.token
|> Task.onError handleDbError
|> Task.andThen generateConfirmationCode
|> Task.andThen (\code -> confirmEmailSuccess response code)
|> Task.onError (Route.Error.invalidRequestData response)


confirmEmailSuccess : Response -> String -> Task x Response
confirmEmailSuccess response code =
let
json =
Json.Encode.object
[ { key = "confirmationCode", value = Json.Encode.string code }
]
in
response
|> Response.setStatus 200
|> Response.setHeader "Content-Type" "application/json"
|> Response.setBody (Json.Encode.encode 0 json)
|> Task.succeed


-- ACTIONS


Expand All @@ -57,12 +137,12 @@ createSession db secureContext user =
|> Task.mapError DbError


sendEmailValidationLink : Postmark.Configuration -> Session -> Task Error Session
sendEmailValidationLink postmarkConfig session =
sendEmailConfirmationLink : Postmark.Configuration -> Session -> Task Error Session
sendEmailConfirmationLink postmarkConfig session =
{ to = session.user.email |> Email.toString
, subject = "Gren: Confirm your email address"
, textBody =
"TODO: link with this token: " ++ session.emailValidationToken
"TODO: link with this token: " ++ session.emailConfirmationToken
}
|> Postmark.send postmarkConfig
|> Task.mapError SendEmailFailed
Expand Down
Loading
Loading