Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat periodic session checks #2032

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
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
10 changes: 10 additions & 0 deletions backend/flow_api/flow/shared/hook_issue_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ func (h IssueSession) Execute(c flowpilot.HookExecutionContext) error {
return fmt.Errorf("failed to generate JWT: %w", err)
}

claims, err := dto.GetClaimsFromToken(rawToken)
if err != nil {
return fmt.Errorf("failed to get token claims: %w", err)
}

err = c.Payload().Set("claims", claims)
if err != nil {
return fmt.Errorf("failed to set token claims to payload: %w", err)
}

activeSessions, err := deps.Persister.GetSessionPersisterWithConnection(deps.Tx).ListActive(userId)
if err != nil {
return fmt.Errorf("failed to list active sessions: %w", err)
Expand Down
20 changes: 5 additions & 15 deletions frontend/elements/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,10 @@ const defaultOptions = {
translationsLocation: "/i18n", // The URL or path where the translation files are located.
fallbackLanguage: "en", // The fallback language to be used if a translation is not available.
storageKey: "hanko", // The name of the cookie the session token is stored in and the prefix / name of local storage keys
cookieDomain: undefined, // The domain where the cookie set from the SDK is available. When undefined,
cookieDomain: undefined, // The domain where the cookie set from the SDK is available. When undefined,
// defaults to the domain of the page where the cookie was created.
cookieSameSite: "lax", // Specify whether/when cookies are sent with cross-site requests.
sessionCheckInterval: 30000, // Interval for session validity checks in milliseconds. Must be greater than 3000 (3s).
};

const { hanko } = await register(
Expand Down Expand Up @@ -246,24 +247,13 @@ The callback function will be called when the event happens and an object will b
##### Session Created

Will be triggered after a session has been created and the user has completed possible additional steps (e.g. passkey
registration or password recovery). It will also be triggered when the user logs in via another browser window. The
event can be used to obtain the JWT.

Please note, that the JWT is only available, when the Hanko-API configuration allows to obtain the JWT. When using
Hanko-Cloud the JWT is always present, for self-hosted Hanko-APIs you can restrict the cookie to be readable by the
backend only, as long as your backend runs under the same domain as your frontend. To do so, make sure the config
parameter "session.enable_auth_token_header" is turned off via the
[Hanko-API configuration](https://github.com/teamhanko/hanko/wiki). If you want the JWT to
be contained in the event details, you need to turn on "session.enable_auth_token_header" when using a cross-domain
setup. When it's a same-domain setup you need to turn off "session.cookie.http_only" to make the JWT accessible to the
frontend.
registration, password recovery, etc.). It will also be triggered when the user logs in via another browser window. The
event can be used to obtain the JWT claims.

```js
hanko.onSessionCreated((sessionDetail) => {
// A new JWT has been issued.
console.info(
`Session created or updated (jwt: ${sessionDetail.jwt})`
);
console.info("Session created", sessionDetail.claims);
});
```

Expand Down
3 changes: 3 additions & 0 deletions frontend/elements/src/Elements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface RegisterOptions {
storageKey?: string;
cookieDomain?: string;
cookieSameSite?: CookieSameSite;
sessionCheckInterval?: number;
}

export interface RegisterResult {
Expand Down Expand Up @@ -134,6 +135,7 @@ export const register = async (
translationsLocation: "/i18n",
fallbackLanguage: "en",
storageKey: "hanko",
sessionCheckInterval: 30000,
...options,
};

Expand All @@ -142,6 +144,7 @@ export const register = async (
cookieDomain: options.cookieDomain,
cookieSameSite: options.cookieSameSite,
localStorageKey: options.storageKey,
sessionCheckInterval: options.sessionCheckInterval,
});
globalOptions.injectStyles = options.injectStyles;
globalOptions.enablePasskeys = options.enablePasskeys;
Expand Down
11 changes: 4 additions & 7 deletions frontend/elements/src/contexts/AppProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import {
Hanko,
HankoError,
TechnicalError,
UnauthorizedError,
WebauthnSupport,
} from "@teamhanko/hanko-frontend-sdk";

Expand Down Expand Up @@ -439,7 +438,9 @@ const AppProvider = ({
JSON.stringify(state.payload.last_login),
);
}
hanko.relay.dispatchSessionCreatedEvent(hanko.session.get());
const { claims } = state.payload;
const expirationSeconds = Date.parse(claims.expiration) - Date.now();
hanko.relay.dispatchSessionCreatedEvent({ claims, expirationSeconds });
lastActionSucceeded();
},
profile_init(state) {
Expand Down Expand Up @@ -548,11 +549,7 @@ const AppProvider = ({
flowInit("/registration").catch(handleError);
break;
case "profile":
if (hanko.session.isValid()) {
flowInit("/profile").catch(handleError);
} else {
setPage(<ErrorPage error={new UnauthorizedError()} />);
}
flowInit("/profile").catch(handleError);
break;
}
},
Expand Down
9 changes: 5 additions & 4 deletions frontend/elements/src/example.html
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@

// Function to add event listeners
function addEventListeners() {
hankoEventsEl.addEventListener("onSessionCreated", () => {
hankoEventsEl.addEventListener("onSessionCreated", (event) => {
// The user has completed the authentication flow through the hanko-auth component, so we can display the
// hanko-profile and hide the hanko-auth component.
showAuthComponent(false); // Show profile component
Expand Down Expand Up @@ -283,11 +283,12 @@
}

// Initialization function
function init() {
async function init() {
addEventListeners();

// Check if a valid session exists
if (hanko.session.isValid()) {
const session = await hanko.sessionClient.validate();
if (session.is_valid) {
showAuthComponent(false); // Show profile component
setVisibility(logoutButtonEl, true); // Show the logout button
}
Expand All @@ -298,7 +299,7 @@
}

// Call the initialization function
init();
await init();
</script>
</body>
</html>
19 changes: 8 additions & 11 deletions frontend/frontend-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,12 @@ You can pass certain options, when creating a new `Hanko` instance:

```js
const defaultOptions = {
timeout: 13000, // The timeout (in ms) for the HTTP requests.
cookieName: "hanko", // The cookie name under which the session token is set.
localStorageKey: "hanko" // The prefix / name of the localStorage keys.
timeout: 13000, // The timeout (in ms) for the HTTP requests.
cookieName: "hanko", // The cookie name under which the session token is set.
localStorageKey: "hanko", // The prefix / name of the localStorage keys.
sessionCheckInterval: 30000, // Interval (in ms) for session validity checks. Must be greater than 3000 (3s).
sessionCheckChannelName: "hanko-session-check" // The broadcast channel name for inter-tab communication

};
const hanko = new Hanko("http://localhost:3000", defaultOptions);
```
Expand Down Expand Up @@ -164,18 +167,12 @@ The following events are available:

- "hanko-session-created": Will be triggered after a session has been created and the user has completed possible
additional steps (e.g. passkey registration or password recovery). It will also be triggered when the user logs in via
another browser window. The event can be used to obtain the JWT. Please note, that the
JWT is only available, when the Hanko API configuration allows to obtain the JWT. When using Hanko-Cloud
the JWT is always present, for self-hosted Hanko-APIs you can restrict the cookie to be readable by the backend only, as long as
your backend runs under the same domain as your frontend. To do so, make sure the config parameter "session.enable_auth_token_header"
is turned off via the [Hanko-API configuration](https://github.com/teamhanko/hanko/wiki). If you want the JWT to be contained in the event details, you need to turn on
"session.enable_auth_token_header" when using a cross-domain setup. When it's a same-domain setup you need to turn off
"session.cookie.http_only" to make the JWT accessible to the frontend.
another browser window. The event can be used to obtain the JWT claims.

```js
hanko.onSessionCreated((sessionDetail) => {
// A new JWT has been issued.
console.info(`Session created or updated (user-id: "${sessionDetail.userID}", jwt: ${sessionDetail.jwt})`);
console.info("Session created", sessionDetail.claims);
})
```

Expand Down
31 changes: 24 additions & 7 deletions frontend/frontend-sdk/src/Hanko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@ import { ThirdPartyClient } from "./lib/client/ThirdPartyClient";
import { TokenClient } from "./lib/client/TokenClient";
import { Listener } from "./lib/events/Listener";
import { Relay } from "./lib/events/Relay";
import { Session } from "./lib/Session";
import { CookieSameSite } from "./lib/Cookie";
import { Flow } from "./lib/flow-api/Flow";
import { SessionClient } from "./lib/client/SessionClient";
import { SessionClient, Session } from "./lib/client/SessionClient";

/**
* The options for the Hanko class
Expand All @@ -25,6 +24,8 @@ import { SessionClient } from "./lib/client/SessionClient";
* if email delivery by Hanko is enabled. If email delivery by Hanko is disabled and the
* relying party configures a webhook for the "email.send" event, then the set language is
* reflected in the payload of the token contained in the webhook request.
* @property {number=} sessionCheckInterval - Interval for session validity checks in milliseconds.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • The readme also mentions the following: "Must be greater than 3000 (3s)." I think this info would be useful here too.

  • Also: it looks like it defaults to 30000 if I provide a value under 3000, so no error or the like. Maybe also mention this? Ignore this bulletpoint if I misinterpreted this.

* @property {string=} sessionCheckChannelName - The broadcast channel name for inter-tab communication.
*/
export interface HankoOptions {
timeout?: number;
Expand All @@ -33,6 +34,8 @@ export interface HankoOptions {
cookieSameSite?: CookieSameSite;
localStorageKey?: string;
lang?: string;
sessionCheckInterval?: number;
sessionCheckChannelName?: string;
}

/**
Expand All @@ -50,8 +53,8 @@ class Hanko extends Listener {
enterprise: EnterpriseClient;
token: TokenClient;
sessionClient: SessionClient;
relay: Relay;
session: Session;
relay: Relay;
flow: Flow;

// eslint-disable-next-line require-jsdoc
Expand All @@ -61,6 +64,8 @@ class Hanko extends Listener {
timeout: 13000,
cookieName: "hanko",
localStorageKey: "hanko",
sessionCheckInterval: 30000,
sessionCheckChannelName: "hanko-session-check",
};
if (options?.cookieName !== undefined) {
opts.cookieName = options.cookieName;
Expand All @@ -80,6 +85,15 @@ class Hanko extends Listener {
if (options?.lang !== undefined) {
opts.lang = options.lang;
}
if (
options?.sessionCheckInterval !== undefined &&
options.sessionCheckInterval > 3000
) {
opts.sessionCheckInterval = options.sessionCheckInterval;
}
if (options?.sessionCheckChannelName !== undefined) {
opts.sessionCheckChannelName = options.sessionCheckChannelName;
}

this.api = api;
/**
Expand Down Expand Up @@ -114,14 +128,15 @@ class Hanko extends Listener {
this.sessionClient = new SessionClient(api, opts);
/**
* @public
* @type {Relay}
* @deprecated
* @type {Session}
*/
this.relay = new Relay({ ...opts });
this.session = new Session(api, opts);
/**
* @public
* @type {Session}
* @type {Relay}
*/
this.session = new Session({ ...opts });
this.relay = new Relay(api, opts);
/**
* @public
* @type {Flow}
Expand Down Expand Up @@ -150,6 +165,8 @@ export interface InternalOptions {
cookieSameSite?: CookieSameSite;
localStorageKey: string;
lang?: string;
sessionCheckInterval?: number;
sessionCheckChannelName?: string;
}

export { Hanko };
38 changes: 38 additions & 0 deletions frontend/frontend-sdk/src/lib/Dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,45 @@ export interface Identity {
provider: string;
}

/**
* Represents the claims associated with a session or token.
*
* @interface
* @category SDK
* @subcategory DTO
* @property {string} subject - The subject or identifier of the claims.
* @property {string} [issued_at] - The timestamp when the claims were issued (optional).
* @property {string} expiration - The timestamp when the claims expire.
* @property {string[]} [audience] - The intended audience(s) for the claims (optional).
* @property {string} [issuer] - The entity that issued the claims (optional).
* @property {Pick<Email, "address" | "is_primary" | "is_verified">} [email] - Email information associated with the subject (optional).
* @property {string} session_id - The session identifier linked to the claims.
*/
export interface Claims {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Claims now also contain a username (introduced in #2036), should we also add this here?

subject: string;
issued_at?: string;
expiration: string;
audience?: string[];
issuer?: string;
email?: Pick<Email, "address" | "is_primary" | "is_verified">;
session_id: string;
}

/**
* Represents the response from a session validation or retrieval operation.
*
* @interface
* @category SDK
* @subcategory DTO
* @property {boolean} is_valid - Indicates whether the session is valid.
* @property {Claims} [claims] - The claims associated with the session (optional).
* @property {string} [expiration_time] - The expiration timestamp of the session (optional).
* @property {string} [user_id] - The user ID linked to the session (optional).
*/
export interface SessionCheckResponse {
is_valid: boolean;
claims?: Claims;
expiration_time?: string;
user_id?: string;
}

8 changes: 4 additions & 4 deletions frontend/frontend-sdk/src/lib/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ class InvalidWebauthnCredentialError extends HankoError {
super(
"Invalid WebAuthn credential error",
"invalidWebauthnCredential",
cause
cause,
);
Object.setPrototypeOf(this, InvalidWebauthnCredentialError.prototype);
}
Expand Down Expand Up @@ -168,7 +168,7 @@ class MaxNumOfPasscodeAttemptsReachedError extends HankoError {
super(
"Maximum number of Passcode attempts reached error",
"passcodeAttemptsReached",
cause
cause,
);
Object.setPrototypeOf(this, MaxNumOfPasscodeAttemptsReachedError.prototype);
}
Expand Down Expand Up @@ -266,7 +266,7 @@ class MaxNumOfEmailAddressesReachedError extends HankoError {
super(
"Maximum number of email addresses reached error",
"maxNumOfEmailAddressesReached",
cause
cause,
);
Object.setPrototypeOf(this, MaxNumOfEmailAddressesReachedError.prototype);
}
Expand All @@ -285,7 +285,7 @@ class EmailAddressAlreadyExistsError extends HankoError {
super(
"The email address already exists",
"emailAddressAlreadyExistsError",
cause
cause,
);
Object.setPrototypeOf(this, EmailAddressAlreadyExistsError.prototype);
}
Expand Down
Loading
Loading