{"id":43105775,"url":"https://github.com/tomsoderlund/login-as-a-service","last_synced_at":"2026-01-31T17:54:20.365Z","repository":{"id":36684178,"uuid":"217613060","full_name":"tomsoderlund/login-as-a-service","owner":"tomsoderlund","description":"Simple plug-and-play login/signup/leads service (using email), compatible with Vercel serverless functions","archived":false,"fork":false,"pushed_at":"2024-12-19T19:35:14.000Z","size":494,"stargazers_count":8,"open_issues_count":8,"forks_count":0,"subscribers_count":4,"default_branch":"master","last_synced_at":"2024-12-19T20:27:59.206Z","etag":null,"topics":["authentication","login"],"latest_commit_sha":null,"homepage":"https://login-as-a-service.vercel.app","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/tomsoderlund.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2019-10-25T20:53:04.000Z","updated_at":"2024-12-19T19:35:18.000Z","dependencies_parsed_at":"2024-11-01T18:23:04.581Z","dependency_job_id":"4ae17a85-2555-467f-aeb9-9a6beceb4ce8","html_url":"https://github.com/tomsoderlund/login-as-a-service","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/tomsoderlund/login-as-a-service","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tomsoderlund%2Flogin-as-a-service","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tomsoderlund%2Flogin-as-a-service/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tomsoderlund%2Flogin-as-a-service/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tomsoderlund%2Flogin-as-a-service/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/tomsoderlund","download_url":"https://codeload.github.com/tomsoderlund/login-as-a-service/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tomsoderlund%2Flogin-as-a-service/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":28948880,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-01-31T14:26:55.697Z","status":"ssl_error","status_checked_at":"2026-01-31T14:26:52.545Z","response_time":128,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.6:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["authentication","login"],"created_at":"2026-01-31T17:54:19.839Z","updated_at":"2026-01-31T17:54:20.358Z","avatar_url":"https://github.com/tomsoderlund.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Login-as-a-Service\n\n_Simple plug-and-play login/signup/leads service (using email), compatible with Vercel serverless functions._\n\n## Features\n\n- Sign up/Login via “magic link” email (no password)\n  - Also “signup-direct” mode with quick signup without email verification\n- Lead collection\n- Payments with Stripe Link: one-time purchases, credits, and subscriptions\n- Invites (coming)\n- SMS support (coming)\n\n## How to use (login/signup flow)\n\n### How to set up a new App\n\n1. Set up your `app` in the database. Point `redirect_url` to the page to process logins (e.g. `https://myapp.com/authenticate`).\n2. Set up Mailgun for email (see below). Place Mailgun values for domain, API key, (server) in database `app` or as global fallback values `DEFAULT_EMAIL_*`.\n3. Create a client-side login/signup form in your app code.\n\n### How to log in (or signup) a user (Person)\n\n1. ➡️ Submit `POST /api/[app]/login` (or `lead`/`signup`/`signup-direct` – see “Login/Signup/Lead” below), with body of at least `{ email }`.\n2. 📧 An email is sent to the user, when link is clicked they are forwarded to `redirect_url` page with query `?token=` (a JWT token with `person_app.user_id` encrypted).\n3. ⬅️ Fetch `GET /api/[app]/people/[token]` to get the User object.\n4. 💾 Store (at least) `token` in a cookie/localStorage.\n\n### Example client-side code\n\nA React Hook implementing steps 1 (`loginUser`):\n\n    export const useUser = function () {\n      const [user, setUser] = useState()\n\n      useEffect(() =\u003e {\n        const userObject = window.localStorage.getItem(STORAGE_USER_KEY)\n        const user = (userObject !== null) ? JSON.parse(userObject) : undefined\n        console.log('User:', user)\n        setUser(user)\n      }, [])\n\n      const loginUser = async (personInfo) =\u003e {\n        const result = await fetch(`${config.loginService}/signup`, {\n          method: 'POST',\n          headers: {\n            Accept: 'application/json',\n            'Content-Type': 'application/json'\n          },\n          body: JSON.stringify(personInfo)\n        })\n        if (result.status === 200) {\n          googleEvent('user_signup')\n          return true\n        } else {\n          console.warn({ result })\n          const json = await result.json()\n          throw new Error(`Login error: ${json.message}`)\n        }\n      }\n\n...and steps 3-4:\n\n      const authenticateUser = async (token) =\u003e {\n        if (!token) return\n        const person = await fetch(`${config.loginService}/people/${token}`).then(res =\u003e res.json())\n        const { username } = person\n        if (!username) throw new Error(`Could not log in user – user token is invalid`)\n        const userObj = { username, token }\n        if (isClientSide()) window.localStorage.setItem(STORAGE_USER_KEY, JSON.stringify(userObj))\n        return person\n      }\n\n      return { user, loginUser, authenticateUser }\n    }\n\n### Mailgun setup\n\nhttps://app.mailgun.com/app/sending/domains/mg.MYDOMAIN.com\n\n![Mailgun setup](docs/mailgun_setup.png)\n\n\n## How to Start\n\n    yarn dev\n\n\n## API\n\n### Login/Signup/Lead\n\n- `POST /api/[app]/lead`: Create new lead (create new user if it doesn’t exist, don’t send email)\n- `POST /api/[app]/signup`: Create new user (create new user if it doesn’t exist, send login email)\n- `POST /api/[app]/signup-direct`: Create new user without email verification (create new user only if it doesn’t exist _in this app_ – otherwise treat as `login` (send login email) and responds with `person` object including JWT `token`)\n- `POST /api/[app]/login`: Login existing user (don’t create new user, send login email)\n\nJSON fields:\n\n- `email` (required)\n- `username` (autogenerated from `email` if not provided)\n- `firstName`\n- `lastName`\n- `country` (code)\n- `message` (user feedback, e.g. a comment box)\n- `...metadata` for everything else\n\n### Get user info and statistics\n\n- `GET /api/[app]/people/[token]`: get user info from a JWT token:\n\n    ```\n    {\n      \"user_id_numeric\": 123,\n      \"user_id\": \"e93fc8605c0940a0af6ce0fdb22f2e5c\",\n      \"username\": \"tomsoderlund\",\n      \"email\": \"tomsoderlund@email.com\",\n      \"first_name\": \"Tom\",\n      \"last_name\": null,\n      \"country\": null,\n      \"can_login\": true,\n      \"subscribe_email\": true,\n      \"subscribe_sms\": true\n    }\n    ```\n\n- `GET /api/apps/[app-secret]/people`: get user list with email address.\n- `GET /api/apps/[app-secret]/feedback`: get list of user feedback.\n- `GET /api/stats`: see user count etc for every app.\n\n### Payments with Stripe\n\nhttp://localhost:3102/test-payments.html\n\n- `POST /api/[app]/people/[token]/payments/purchase`: Create a one-time purchase:\n\n\t```\n\t{\n    amount: 500,\n    currency: 'usd',\n    productName: 'Product',\n    quantity: 1,\n    successUrl: '[ORIGIN]/success',\n    cancelUrl: '[ORIGIN]/cancel'\n  }\n\t```\n\n- `POST /api/[app]/people/[token]/payments/purchase-credits`: Buy credits (price and currency set in `app` table):\n\n\t```\n\t{\n    quantity: 10, // Nr of credits to purchase\n    // You can use same props as with /payments/purchase but not needed\n  }\n\t```\n\n- `POST /api/[app]/people/[token]/payments/use-credits`: Use/consume credits: `{ quantity: 1 }`\n- `POST /api/[app]/people/[token]/payments/subscription`: Create a recurring subscription (`priceId` is from Stripe):\n\n\t```\n\t{\n    priceId,\n    quantity = 1\n  }\n\t```\n\n## Todo\n\n- [ ] Support multiple apps per user in shared metadata\n- [ ] Subscriptions\n  - [ ] Send email/SMS to all subscribers\n  - [ ] Subscribed (field in person_app)\n  - [ ] Unsubscribe route\n- [ ] Invite other users\n- [ ] SMS support\n- [ ] Approve leads: set flag + send email\n\nMaybe:\n\n- [ ] Create app via API (needed? or use metadata?): POST /api/apps\n- [ ] Redirect: GET /api/[app]/redirect then forward (set is_confirmed + tracking)\n- [ ] Time-limited tokens\n- [ ] Is this token valid? route Reset token GET /api/[app]/sessions/[jwt]\n- [ ] createPerson “after creation” function\n\ninvalid signature = JWT error\n\nDone:\n\n- [x] 🐜 duplicate key value violates unique constraint \"app_username_unique_idx\" (same username \u0026 same app_id)\n- [x] Fallback email account when not specified on app level\n- [x] Inverted flow (`signup-direct`): sign up, email if NOT user\n- [x] 🐜 Email force lowercase\n- [x] 🐜 Usernames: when only numeric not NULL\n- [x] Also numeric `user_id_numeric` = \n- [x] Signup set existing user can_login = true **DONE?**\n- [x] Unique username for that person_app\n- [x] See stats: /api/stats\n- [x] Email list for each app: /api/apps/SECRET/people\n- [x] Client save cookie\n- [x] GET /api/[app]/people/[token]: token -\u003e person\n- [x] Store userId internally in app\n- [x] Email lib\n- [x] Lead signup: POST /api/[app]/lead\n- [x] Log in: POST /api/[app]/login (won’t create new)\n- [x] POST /api/[app]/signup\n- [x] Boolean “can_login” (lead: false, login/signup: true)\n- [x] Update user: PATCH /api/[app]/people/[token]: Update user\n\n## Feedback form – an example\n\n    import React, { useState, useEffect } from 'react'\n\n    import { config } from 'config/config'\n    import { useUser } from 'hooks/useUser'\n\n    const FeedbackForm = () =\u003e {\n      const { user } = useUser()\n      const [personInfo, setPersonInfo] = useState({ email: '', message: '' })\n      const setPersonInfoField = (field, value) =\u003e setPersonInfo({ ...personInfo, [field]: value })\n\n      useEffect(() =\u003e {\n        if (user?.email) {\n          setPersonInfoField('email', user.email)\n        }\n      }, [user])\n\n      const [showFeedback, setShowFeedback] = useState(false)\n      const [inProgress, setInProgress] = useState(false)\n      const [isSubmitted, setIsSubmitted] = useState(false)\n      const [hasErrors, setHasErrors] = useState(false)\n\n      const handleSubmit = async (event) =\u003e {\n        event.preventDefault()\n        setInProgress(true)\n        try {\n          // Should use the 'lead' mode in leadService\n          const result = await fetch(config.leadService, { // eslint-disable-line no-undef\n            method: 'POST',\n            headers: {\n              Accept: 'application/json',\n              'Content-Type': 'application/json'\n            },\n            body: JSON.stringify(personInfo)\n          })\n          if (result.status === 200) {\n            setIsSubmitted(true)\n          } else {\n            const json = await result.json()\n            setHasErrors(json.message)\n          }\n        } catch (err) {\n          console.warn(`Warning: ${err.message || err}`)\n          setHasErrors(err.message)\n        } finally {\n          setInProgress(false)\n        }\n      }\n\n      return (\n        \u003c\u003e\n          \u003cbutton title='Leave feedback' onClick={() =\u003e { setShowFeedback(!showFeedback); setIsSubmitted(false) }} className='circle-menu-button bottom right grow-once'\u003e\u003cimg src='/icons/feedback.svg' alt='Feedback' /\u003e\u003c/button\u003e\n\n          {showFeedback ? (\n            \u003cform onSubmit={handleSubmit}\u003e\n              \u003cbutton onClick={() =\u003e setShowFeedback(false)} className='no-button close-button'\u003e✖️\u003c/button\u003e\n              {!isSubmitted ? (\n                \u003c\u003e\n                  \u003clabel\u003eLeave feedback:\u003c/label\u003e\n                  \u003cinput\n                    required\n                    name='email'\n                    type='email'\n                    value={personInfo.email}\n                    placeholder='Your email'\n                    onChange={event =\u003e setPersonInfoField('email', event.target.value)}\n                    disabled={inProgress}\n                  /\u003e\n                  \u003ctextarea\n                    required\n                    name='message'\n                    value={personInfo.message}\n                    placeholder='Your feedback'\n                    onChange={event =\u003e setPersonInfoField('message', event.target.value)}\n                    disabled={inProgress}\n                    rows={5}\n                  /\u003e\n                  \u003cbutton\n                    type='submit'\n                    className={'primary progress-animation' + (inProgress ? ' in-progress' : '')}\n                    disabled={inProgress}\n                  \u003e\n                    Send\n                  \u003c/button\u003e\n                  {hasErrors ? \u003cp className='error color-error-fg'\u003e{hasErrors}\u003c/p\u003e : null}\n                \u003c/\u003e\n              ) : (\n                \u003cp className='thankyou'\u003eThank you for your feedback!\u003c/p\u003e\n              )}\n              \u003cstyle jsx\u003e{`\n                form {\n                  position: fixed;\n                  bottom: 1.5rem;\n                  right: 3.5rem;\n                  padding: 1em;\n                  background-color: #F5F5F5;\n                  box-shadow: 0 0 1em rgba(0, 0, 0, 0.5);\n                  border-radius: 0.5em;\n                  display: flex;\n                  flex-direction: column;\n                  font-size: 1rem;\n                }\n\n                .close-button {\n                  position: absolute;\n                  top: 0.2rem;\n                  right: 0.2rem;\n                  font-size: 1.2rem;\n                }\n\n                label {\n                  margin-bottom: 0.5em;\n                }\n\n                input:not([type=\"radio\"]):not([type=\"checkbox\"]):not([type=\"color\"]):not([type=\"range\"]), .input, textarea, select {\n                  margin-right: 0;\n                  padding: 0.5em;\n                }\n\n                @media only screen and (max-width: 480px) {\n                  form {\n                    right: 0.5rem;\n                    left: 0.5rem;\n                    bottom: 4rem;\n                  }\n                }\n              `}\n              \u003c/style\u003e\n            \u003c/form\u003e\n          ) : null}\n        \u003c/\u003e\n      )\n    }\n    export default FeedbackForm\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftomsoderlund%2Flogin-as-a-service","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ftomsoderlund%2Flogin-as-a-service","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftomsoderlund%2Flogin-as-a-service/lists"}