You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Ionic集成Keycloak单点登录问题:错误排查与Deep Link实现

问题描述

我希望通过Keycloak实现SSO(单点登录)完成身份校验,但当前代码在模拟器运行时出现错误。同时需要实现Deep Link(深度链接)与InAppBrowser,以获取必要的Token,请问如何在现有代码中实现浏览器标签页与Deep Link功能来获取Token?

相关代码

app.module.ts

/* eslint-disable prefer-const */
import { ApplicationRef, DoBootstrap, NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router';

import { IonicModule, IonicRouteStrategy } from '@ionic/angular';

import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { HttpClientModule } from '@angular/common/http';
import { KeycloakService } from 'keycloak-angular';
import { CommonKeycloakService } from './services/keycloak/common-keycloak-service.service';
import { environment } from 'src/environments/environment';
import { InAppBrowser} from '@ionic-native/in-app-browser/ngx';
const keycloakService = new KeycloakService();
@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule, HttpClientModule],
  providers: [CommonKeycloakService,
    {
      provide: KeycloakService,
      useValue: keycloakService
    }, { provide: RouteReuseStrategy, useClass: IonicRouteStrategy },InAppBrowser]
  // bootstrap: [AppComponent],
})
export class AppModule implements DoBootstrap {
  constructor() { }

  ngDoBootstrap(appRef: ApplicationRef) {
    let configKeyClock: any = {
      url: environment.keycloak.url,
      realm: environment.keycloak.realm,
      clientId: environment.keycloak.clientId,
    };
    keycloakService
      .init({
        config: configKeyClock,
        initOptions: {
          onLoad: 'check-sso',
          checkLoginIframe: false,
        },
        enableBearerInterceptor: true,
      })
      .then(() => {
        console.log('[ngDoBootstrap] bootstrap app');
        appRef.bootstrap(AppComponent);
      })
      .catch((error: any) =>
        console.error('[ngDoBootstrap] init Keycloak failed', error)
      );
  }
}

app.component.ts

/* eslint-disable max-len */
/* eslint-disable @typescript-eslint/dot-notation */
/* eslint-disable prefer-const */
import { Component } from '@angular/core';
import { InAppBrowser } from '@ionic-native/in-app-browser/ngx';
import { PrimaryService } from './primary.service';
import { CommonKeycloakService } from './services/keycloak/common-keycloak-service.service';
@Component({
  selector: 'app-root',
  templateUrl: 'app.component.html',
  styleUrls: ['app.component.scss'],
})
export class AppComponent {

  constructor(private user: PrimaryService, private commonKeycloakService: CommonKeycloakService,
    public keycloak: CommonKeycloakService,private iab: InAppBrowser) {

  }

  async ngOnInit() {
    await this.getAuthInfo();
    if (sessionStorage.getItem('userDetails') == null) {
      this.login();
    }
  };

  login() {
    if (sessionStorage.getItem('userDeatails') == null) {
      let IDP = 'samlNew';
      this.keycloak.login(IDP);
      const browser = this.iab.create('https://dbcluster.890enterprise.business/auth/realms/890Enterprise/protocol/openid-connect/auth', '_blank', 'location=yes');
    }
  }

  async getAuthInfo() {
    if (this.commonKeycloakService.isUserLoggedIn()) {
      this.commonKeycloakService.keycloakAngular
        .loadUserProfile()
        .then(async (profiles) => {
          sessionStorage.setItem('userDetails', JSON.stringify(profiles));
          // await this._httpService.generateJwt(profiles);
          let userInfo = sessionStorage.getItem('userDetails');
          let userInfo1 = JSON.parse(userInfo);
          let userName: string = userInfo1['firstName'];
          // this._httpService.userName.next(userName);
        })
        .catch((error) => {
          console.log('Error in loading profile' + JSON.stringify(error));
        });
    } else {
    }
  }
}

Keycloak服务文件

/* eslint-disable max-len */
/* eslint-disable prefer-const */
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable } from '@angular/core';
import { KeycloakService } from 'keycloak-angular';
import { environment } from 'src/environments/environment';
import { Observable } from 'rxjs';
import { InAppBrowser as brand} from '@ionic-native/in-app-browser/ngx';

@Injectable({
  providedIn: 'root'
})
export class CommonKeycloakService {
  activityStart: any;
  constructor(public keycloakAngular: KeycloakService,public iab: brand) {
    // this.redirectUri = environment.keycloak.url + '/realms/'
    //  + environment.keycloak.realm + '/protocol/openid-connect/logout?redirect_uri=' +
    //   environment.baseUrl + '/index.html';

    this.redirectUri = environment.keycloakRedirectBaseURl + '/index.html';
    this.activityStart = Math.floor(Date.now() / 1000);

  }



  redirectUri: any;
  idpHint: string = environment.defaultTenant;
  tenantInfo: any;
  userInfo: any;
  loginId: any;
  private _tenantcontroller = `${environment.tenantUrl}`;

  isUserLoggedIn(): boolean {
    // return true;
    if (this.keycloakAngular.isLoggedIn()) {
      try {
        const userDetails = this.keycloakAngular.getUsername();
        return true;
      } catch (e) {
        return false;
      }

    } else {
      return false;
    }

  }

  public getRedirectURI(): string {
    return this.redirectUri;
  }

  public logout() {
    this.signoutApi();
  }

  private tInf() {
    let t: any = sessionStorage.getItem('tenantInfo');
    return t;
  }

  private uInf() {
    let u: any = sessionStorage.getItem('userDetails');
    return u;
  }

  public signoutApi() {
    this.keycloakAngular.logout(this.redirectUri);
    this.keycloakAngular.logout(environment.keycloakRedirectBaseURl + '/#/auth');
  }



  public login(id: any) {
    this.idpHint = id;
    this.keycloakAngular.login({ idpHint: this.idpHint });
  }

  public setTenantInfo(info: any) {

    sessionStorage.setItem('tenantInfo', JSON.stringify(info));
  }
  public getTenantInfo() {
    return JSON.parse(this.tInf());
  }
  public setLoginId(loginId: any) {
    sessionStorage.setItem('loginId', loginId);
  }


  public getLoginId() {
    let lgnID: any = sessionStorage.getItem('loginId');
    return JSON.parse(lgnID);
  }
  public setTenantProfileInfo(info: any) {
    sessionStorage.setItem('tenantProfileInfo', JSON.stringify(info));
  }
  public getuserDetails() {
    return JSON.parse(this.uInf());
  }

  public getUserDetailsObservable(): Observable<any> {
    const userDetail = new Observable(observer => {
      setTimeout(() => {
        observer.next(JSON.parse(this.uInf()));
      }, 2500);
    });
    return userDetail;
  }

  public getTenantProfileInfo1(): Observable<any> {
    let tPInfo: any = sessionStorage.getItem('tenantProfileInfo');
    const tenantProfileInfo = new Observable(observer => {
      setTimeout(() => {
        observer.next(JSON.parse(tPInfo));
      }, 1500);
    });
    return tenantProfileInfo;
  }

  public getTenantProfileInfo() {
    let tPInfo: any = sessionStorage.getItem('tenantProfileInfo');
    return JSON.parse(tPInfo);
  }
  public clearTenantInfo() {
    sessionStorage.removeItem('tenantInfo');
  }
  public getUserDetails() {
    if (this.isUserLoggedIn()) {
      return JSON.stringify(this.keycloakAngular.loadUserProfile());
    } else {
      return '';
    }
  }

  public getToken() {
    return this.keycloakAngular.getToken();
  }

}

模拟器错误截图

模拟器错误截图


解决方案

一、修复当前模拟器错误

从截图报错Uncaught (in promise): Error: Could not authenticate分析,核心问题是重复触发登录流程:login()方法同时调用了Keycloak默认登录和InAppBrowser,导致认证逻辑冲突。先修改app.component.ts的login()方法:

login() {
  // 修复拼写错误:userDeatails → userDetails
  if (sessionStorage.getItem('userDetails') == null) { 
    let IDP = 'samlNew';
    // 拼接完整的Keycloak授权URL,包含必要参数
    const loginUrl = `${environment.keycloak.url}/realms/${environment.keycloak.realm}/protocol/openid-connect/auth` +
      `?client_id=${environment.keycloak.clientId}` +
      `&redirect_uri=${encodeURIComponent(environment.keycloakRedirectBaseURl)}` +
      `&response_type=code` +
      `&idp_hint=${IDP}`;
    
    // 用InAppBrowser打开登录页,替代Keycloak默认浏览器跳转
    const browser = this.iab.create(loginUrl, '_blank', 'location=yes,hidden=no');
    
    // 监听页面加载,捕获授权码回调
    browser.on('loadstart').subscribe(event => {
      if (event.url.startsWith(environment.keycloakRedirectBaseURl)) {
        const urlParams = new URLSearchParams(event.url.split('?')[1]);
        const code = urlParams.get('code');
        
        if (code) {
          browser.close();
          // 用授权码交换Token
          this.commonKeycloakService.exchangeCodeForToken(code);
        }
      }
    });
  }
}

二、配置Deep Link接收回调

1. 移动端Deep Link插件配置(以Capacitor为例)

安装插件并同步配置:

npm install @capacitor/deeplinks
npx cap sync

修改capacitor.config.ts:

import { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'com.your.app.id',
  appName: 'YourApp',
  webDir: 'www',
  bundledWebRuntime: false,
  plugins: {
    Deeplinks: {
      appId: 'com.your.app.id',
      scheme: 'yourapp', // 自定义协议,如yourapp://
      domains: ['your-domain.com'], // 可选:绑定域名回调
    },
  },
};

export default config;

2. Keycloak后台配置

在Keycloak管理后台找到对应客户端,修改Valid Redirect URIs为你的Deep Link地址,比如yourapp://callback/*或配置的域名地址。

3. 监听Deep Link回调

在app.component.ts添加监听逻辑:

import { Deeplinks } from '@capacitor/deeplinks';

async ngOnInit() {
  await this.setupDeepLinkListener();
  await this.getAuthInfo();
  if (sessionStorage.getItem('userDetails') == null) {
    this.login();
  }
};

async setupDeepLinkListener() {
  await Deeplinks.addListener('deepLinkOpen', async (event) => {
    const urlParams = new URLSearchParams(event.url.split('?')[1]);
    const code = urlParams.get('code');
    
    if (code) {
      await this.commonKeycloakService.exchangeCodeForToken(code);
    }
  });
}

三、实现授权码交换Token

在CommonKeycloakService中添加Token交换方法:

import { HttpClient } from '@angular/common/http';

// 修改构造函数注入HttpClient
constructor(
  public keycloakAngular: KeycloakService, 
  public iab: brand, 
  private http: HttpClient
) {
  // 原有代码...
}

async exchangeCodeForToken(code: string) {
  const tokenUrl = `${environment.keycloak.url}/realms/${environment.keycloak.realm}/protocol/openid-connect/token`;
  
  const body = new FormData();
  body.append('grant_type', 'authorization_code');
  body.append('client_id', environment.keycloak.clientId);
  body.append('code', code);
  body.append('redirect_uri', environment.keycloakRedirectBaseURl);
  
  try {
    const tokens = await this.http.post(tokenUrl, body).toPromise() as any;
    
    // 更新Keycloak实例的Token信息
    this.keycloakAngular['instance'].token = tokens.access_token;
    this.keycloakAngular['instance'].refreshToken = tokens.refresh_token;
    this.keycloakAngular['instance'].idToken = tokens.id_token;
    
    // 加载用户信息
    await this.loadUserProfile();
    sessionStorage.setItem('userDetails', JSON.stringify(this.getuserDetails()));
  } catch (error) {
    console.error('Token交换失败:', error);
  }
}

四、修复其他代码问题

  1. 修正CommonKeycloakService中重复调用logout()的问题:
public signoutApi() {
  this.keycloakAngular.logout(environment.keycloakRedirectBaseURl + '/#/auth');
}
  1. 确保environment.ts中配置正确的keycloakRedirectBaseURl,对应Deep Link地址或移动端可访问的回调地址。

五、验证流程

  1. 启动模拟器,App触发登录后打开InAppBrowser加载Keycloak登录页;
  2. 用户完成登录后,Keycloak跳转至配置的Deep Link,App捕获授权码;
  3. 自动完成Token交换,加载用户信息,完成SSO流程。

内容的提问来源于stack exchange,提问作者irfan shaikh

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.29 14:57:04