@e280/authlocal
Version:
User-sovereign login system for everybody
145 lines (125 loc) • 3.8 kB
text/typescript
import {sub} from "@e280/stz"
import {signal} from "@benev/slate"
import {AuthOptions} from "./types.js"
import {defaults} from "./parts/defaults.js"
import {AuthStores} from "./parts/stores.js"
import {Login} from "../trust/exports/app.js"
import {openPopup} from "./parts/open-popup.js"
import {setupInApp} from "../trust/postmessage/setup-in-app.js"
import {Session} from "../trust/exports/authority.js"
import {nullcatch} from "../common/utils/nullcatch.js"
/**
* Authlocal's page-level auth control center.
* - there should only be one instance on the page, shared across any authlocal elements.
* - provides the `login` state
* - handles persistence of the login session into storage
* - coordinates and communicates with the Authlocal popup
*/
export class Auth {
static version = 1
static defaults = defaults
/** The url that the login popups should use (defaults to "https://authlocal.org/") */
src: string
/**
* Subscribe to changes in the login state.
* - if the login is `null`, it means the user has logged out.
* - usage:
* auth.on(login => console.log(login))
*/
on = sub<[Login | null]>()
#options: AuthOptions
#stores: AuthStores
#ready: Promise<void>
#login = signal<Login | null>(null)
constructor(options: Partial<AuthOptions> = {}) {
this.#options = Auth.defaults(options)
this.src = this.#options.src
this.#stores = new AuthStores(this.#options.kv)
this.#ready = this.#stores.versionMigration(Auth.version)
this.#login.on(login => this.on.pub(login))
this.#options.onStorageChange(() => void this.loadLogin())
}
/** Load and update the login state from storage */
async loadLogin(): Promise<Login | null> {
const login = await this.#getStoredLogin()
return this.#updateLoginSignal(login)
}
/** Set the login state manually, saving it to storage */
async saveLogin(login: Login | null) {
const login2 = await this.#setStoredLogin(login)
return this.#updateLoginSignal(login2)
}
/** Shortcut for `saveLogin(null)` */
async logout() {
return this.saveLogin(null)
}
/** The current login state, either a `Login` object, or null if logged out */
get login() {
const login = this.#login.value
if (login && login.isExpired())
this.#login.value = null
return this.#login.value
}
/**
* Spawn a login popup, requesting for the user to login.
* `src`:
* this is the url to open (defaults to "https://authlocal.org/")
*/
async popup(src = this.src) {
const popupWindow = openPopup(src)
const popupOrigin = new URL(src, window.location.href).origin
if (!popupWindow)
return null
return new Promise<Login | null>((resolve, reject) => {
const appWindow = window
const {dispose} = setupInApp(
appWindow,
popupWindow,
popupOrigin,
async session => {
popupWindow.close()
if (!session)
return undefined
try {
const login = await this.#verify(session)
await this.saveLogin(login)
dispose()
resolve(login)
}
catch (err) {
dispose()
reject(err)
}
},
)
popupWindow.onclose = () => {
dispose()
resolve(this.login)
}
})
}
#updateLoginSignal(login: Login | null) {
const hasChanged = login?.sessionId !== this.#login.value?.sessionId
if (hasChanged)
this.#login.value = login
return login
}
async #verify(session: Session) {
return nullcatch(async() => Login.verify({
session,
appOrigins: [window.origin],
}))
}
async #getStoredLogin() {
await this.#ready
const session = await this.#stores.session.get()
if (!session) return null
return this.#verify(session)
}
async #setStoredLogin(login: Login | null) {
await this.#ready
const session = login?.session
await this.#stores.session.set(session)
return login
}
}